Compare commits
40 Commits
806ebad1c3
...
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 | |||
| c090c18734 | |||
| a07b145c76 | |||
| 6617574c16 | |||
| 001d7ad3c1 | |||
| cd6265e2f2 | |||
| 6906cfe5da | |||
| 39c5021e2f | |||
| 5a8b20a827 |
@@ -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...
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
"": {
|
||||
"name": "item-extractor-solid",
|
||||
"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",
|
||||
},
|
||||
@@ -171,6 +174,8 @@
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="],
|
||||
|
||||
"@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
|
||||
@@ -215,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=="],
|
||||
@@ -249,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=="],
|
||||
@@ -257,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=="],
|
||||
@@ -303,32 +388,82 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"pocketbase": ["pocketbase@0.26.8", "", {}, "sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||
|
||||
"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=="],
|
||||
@@ -337,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=="],
|
||||
@@ -373,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>`
|
||||
+5
-2
@@ -5,12 +5,15 @@
|
||||
<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>
|
||||
|
||||
<body>
|
||||
<item-extractor></item-extractor>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+6
-2
@@ -6,11 +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"
|
||||
},
|
||||
@@ -24,4 +28,4 @@
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-solid": "^2.11.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-1
Submodule src deleted from f12bf53389
+27
@@ -0,0 +1,27 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.solid:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { createSignal } from 'solid-js'
|
||||
import solidLogo from './assets/solid.svg'
|
||||
import viteLogo from '/vite.svg'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = createSignal(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} class="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://solidjs.com" target="_blank">
|
||||
<img src={solidLogo} class="logo solid" alt="Solid logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + Solid</h1>
|
||||
<div class="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count()}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p class="read-the-docs">
|
||||
Click on the Vite and Solid logos to learn more
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Component } from 'solid-js';
|
||||
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">
|
||||
<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
|
||||
href="/"
|
||||
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"
|
||||
end={true}
|
||||
>
|
||||
Item Extractor
|
||||
</A>
|
||||
<A
|
||||
href="/templates"
|
||||
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"
|
||||
>
|
||||
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">
|
||||
{props.children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppContainer;
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createSignal, Show } from 'solid-js';
|
||||
|
||||
import FileUpload from './components/FileUpload';
|
||||
import EstimateBuilder from './components/EstimateBuilder';
|
||||
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, Plus, FilePlus, ArrowLeft } from 'lucide-solid';
|
||||
import { appStore } from './store/appStore';
|
||||
import CloudLoadModal from './components/CloudLoadModal';
|
||||
|
||||
interface ItemExtractorProps {
|
||||
onItemsExtracted?: (items: ExtractedItem[]) => void;
|
||||
onCopy?: (text: string) => void;
|
||||
}
|
||||
|
||||
const ItemExtractor: Component<ItemExtractorProps> = () => {
|
||||
|
||||
const items = appStore.items;
|
||||
const setItems = appStore.setItems;
|
||||
const ignoredLines = appStore.ignoredLines;
|
||||
const setIgnoredLines = appStore.setIgnoredLines;
|
||||
const estimateItems = appStore.estimateItems;
|
||||
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);
|
||||
setItems(extracted);
|
||||
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 && 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">
|
||||
|
||||
<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>
|
||||
</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 || scopes.length > 0}
|
||||
fallback={
|
||||
<div class="text-gray-400 text-center mt-12 opacity-50">
|
||||
<p>Results will appear here</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="w-full">
|
||||
<EstimateBuilder
|
||||
initialItems={items()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={ignoredLines().length > 0}>
|
||||
<div class="mt-8 max-w-4xl w-full no-print">
|
||||
<IgnoredLinesList lines={ignoredLines()} />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Toast show={showToast()} message={toastMessage()} />
|
||||
|
||||
<CloudLoadModal show={showLoadModal()} onClose={() => setShowLoadModal(false)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemExtractor;
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,20 @@
|
||||
export const COMPANY_NAME = "Cardoza Construction, LLC";
|
||||
export const COMPANY_ADDRESS = "1836 N Deffer Dr. Nixa, MO 65714";
|
||||
export const COMPANY_PHONE = "417-725-6717";
|
||||
export const COMPANY_EMAIL = "Sales@cardoza.construction";
|
||||
|
||||
export const GENERAL_NOTES_DEFAULT = `- Client will need to have area professionally cleaned after our work is complete. Our work order includes basic cleanup, unless otherwise specified, but some dust, mud, and residue may remain.
|
||||
- Price is for entire portion of each scope of work to be done in one mobilization (Unless otherwise noted in writing). If multiple mobilizations will be required, please notify our office so we can adjust our schedule and price.
|
||||
- Estimate is based on standard scheduling and working hours. Please allow reasonable time without
|
||||
- Job site is to be clean and free of debris or other trades material. If situation occurs to where it is not clear, we will notify builder/owner first, and then if still not cleaned up we will clean and reasonably charge for time.
|
||||
- Concrete slab, sub-floor, and stairs must be installed prior to work. Additional charges may apply for gravel or other uneven surfaces.
|
||||
- Water, electricity and dumpster are necessities for drywall. Please advise before if any of these will not be available. Builder to supply dumpster, chute or fork lift if needed.
|
||||
- The owner or general contractor is responsible for approval of drywall upon completion, and prior to painting. Cardoza Construction LLC is not responsible, or liable, for any repainting charges.
|
||||
- Heat is required certain months of the year. Our estimates do NOT include heat on jobs, unless specifically noted. If heat is not furnished by contractor or owner, we will furnish heaters and fuel, and these charges will be added to invoice. Lack of heat can significantly affect production and quality of job.
|
||||
- Temperature of work area is to be in accordance with ASTM prior to finishing drywall, installing FRP, or any other material that is reliant on heat or consistent temperatures, and kept at a steady temperature there after, or warranty may be void.
|
||||
- Price does not include any caulking/sealant (fire, acoustical, etc.).
|
||||
- Providing a professional and positive work environment for our team and our customers is core to who we are.
|
||||
|
||||
Foul language, harassment, or yelling does not provide a positive atmosphere for our team. Cardoza Construction reserves the right to withdraw our team from a hostile environment until the situation is corrected which may cause delays to the job or termination of our contract. Client shall terminate under the AIA 14.4 Termination by Owner for Convenience clause.#`;
|
||||
|
||||
export const GENERAL_PRE_NOTES_DEFAULT = `Material and labor pricing is good through #. Materials must be ordered prior to this date to avoid price increase. A material draw is due upon delivery (COD) and labor net 15 days.`;
|
||||
@@ -0,0 +1,240 @@
|
||||
import { createSignal, Show, For, createEffect, createMemo, type Component } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { X, FileBox, CheckCircle2, Search } from 'lucide-solid';
|
||||
import { pb, COLLECTIONS } from '../utils/db';
|
||||
import { appStore } from '../store/appStore';
|
||||
import type { EstimateItem, TemplateItem, Modifier } from '../types';
|
||||
|
||||
interface ApplyTemplateModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
scopeId: string | null;
|
||||
}
|
||||
|
||||
const ApplyTemplateModal: Component<ApplyTemplateModalProps> = (props) => {
|
||||
const subScopes = () => appStore.subScopes.filter(ss => ss.scopeId === props.scopeId);
|
||||
|
||||
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();
|
||||
setTargetSubScopeId('scope'); // reset selection
|
||||
}
|
||||
});
|
||||
|
||||
const loadTemplates = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const records = await pb.collection(COLLECTIONS.TEMPLATES).getFullList({
|
||||
sort: '-created'
|
||||
});
|
||||
setTemplates(records);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to load templates.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyTemplate = (record: any) => {
|
||||
const templateItems: TemplateItem[] = record.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,
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice,
|
||||
markup: item.markup,
|
||||
markupType: item.markupType,
|
||||
contingency: item.contingency,
|
||||
contingencyType: item.contingencyType,
|
||||
scopeId: props.scopeId || 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();
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.show && props.scopeId}>
|
||||
<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-primary/10 text-primary rounded-xl">
|
||||
<FileBox class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-black">Apply Template</h3>
|
||||
<p class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider mt-0.5">Append items to scope</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-6 overflow-hidden">
|
||||
{/* 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-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={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>
|
||||
</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" />
|
||||
<p class="text-sm font-medium">No saved templates found.</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplyTemplateModal;
|
||||
@@ -0,0 +1,392 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createSignal, For, Show, onMount, onCleanup } from 'solid-js';
|
||||
import { Calculator, X, Trash2, Plus, Minus, Asterisk, Divide, Equal, Flag } from 'lucide-solid';
|
||||
|
||||
interface Entry {
|
||||
id: string;
|
||||
type: 'number' | 'field' | 'marker' | 'result';
|
||||
value?: number;
|
||||
label?: string;
|
||||
markerName?: string;
|
||||
markerTime?: string;
|
||||
}
|
||||
|
||||
import { Portal } from 'solid-js/web';
|
||||
|
||||
const CalculatorPopover: Component = () => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [entries, setEntries] = createSignal<Entry[]>([]);
|
||||
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);
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsHovered(false);
|
||||
const dataStr = e.dataTransfer?.getData('application/json');
|
||||
if (dataStr) {
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
if (data.type === 'item-field') {
|
||||
addValue(data.value, data.label);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse dropped data', err);
|
||||
}
|
||||
} else {
|
||||
const text = e.dataTransfer?.getData('text/plain');
|
||||
if (text && !isNaN(parseFloat(text))) {
|
||||
addValue(parseFloat(text), 'Dropped Value');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addValue = (val: number, label?: string) => {
|
||||
const op = operator();
|
||||
if (!op && entries().length > 0 && entries()[entries().length - 1].type !== 'result' && entries()[entries().length - 1].type !== 'marker') {
|
||||
// If no operator but we have a previous value that isn't a result/marker, default to addition
|
||||
let nextTotal = runningTotal() + val;
|
||||
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: label ? 'field' : 'number', value: val, label }]);
|
||||
setRunningTotal(nextTotal);
|
||||
} else if (!op) {
|
||||
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: label ? 'field' : 'number', value: val, label }]);
|
||||
setRunningTotal(val);
|
||||
} else {
|
||||
let nextTotal = runningTotal();
|
||||
if (op === '+') nextTotal += val;
|
||||
if (op === '-') nextTotal -= val;
|
||||
if (op === '*') nextTotal *= val;
|
||||
if (op === '/') nextTotal = val !== 0 ? nextTotal / val : 0;
|
||||
|
||||
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: label ? 'field' : 'number', value: val, label }]);
|
||||
setRunningTotal(nextTotal);
|
||||
setOperator(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNumberClick = (num: string) => {
|
||||
setCurrentInput(prev => prev + num);
|
||||
};
|
||||
|
||||
const handleOperatorClick = (op: '+' | '-' | '*' | '/') => {
|
||||
if (currentInput()) {
|
||||
addValue(parseFloat(currentInput()));
|
||||
setCurrentInput('');
|
||||
}
|
||||
setOperator(op);
|
||||
};
|
||||
|
||||
const handleEquals = () => {
|
||||
if (currentInput()) {
|
||||
addValue(parseFloat(currentInput()));
|
||||
setCurrentInput('');
|
||||
}
|
||||
|
||||
// Add result entry to history
|
||||
const result = runningTotal();
|
||||
setEntries(prev => [...prev, { id: crypto.randomUUID(), type: 'result', value: result }]);
|
||||
setOperator(null);
|
||||
};
|
||||
|
||||
const createMarker = () => {
|
||||
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 = () => {
|
||||
setRunningTotal(0);
|
||||
setOperator(null);
|
||||
setCurrentInput('');
|
||||
};
|
||||
|
||||
const resetHistory = () => {
|
||||
if (confirm('Clear entire history?')) {
|
||||
setEntries([]);
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
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 === '.') {
|
||||
if (!currentInput().includes('.')) handleNumberClick('.');
|
||||
} else if (e.key === '+') {
|
||||
handleOperatorClick('+');
|
||||
} else if (e.key === '-') {
|
||||
handleOperatorClick('-');
|
||||
} else if (e.key === '*' || e.key === 'x') {
|
||||
handleOperatorClick('*');
|
||||
} else if (e.key === '/') {
|
||||
handleOperatorClick('/');
|
||||
} else if (e.key === 'Enter' || e.key === '=') {
|
||||
handleEquals();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
} else if (e.key === 'Backspace' && currentInput()) {
|
||||
setCurrentInput(prev => prev.slice(0, -1));
|
||||
} else if (e.key === 'Delete' || (e.key === 'Backspace' && !currentInput())) {
|
||||
clearInput();
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<Portal>
|
||||
<div class="fixed bottom-6 right-6 z-[100] no-print">
|
||||
<Show when={isOpen()}>
|
||||
<div
|
||||
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}
|
||||
>
|
||||
{/* Header */}
|
||||
<div class="p-6 bg-muted/30 border-b border-border flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 text-foreground font-black text-sm uppercase tracking-widest">
|
||||
<div class="p-2 bg-primary rounded-xl text-primary-foreground shadow-lg shadow-primary/20">
|
||||
<Calculator class="w-4 h-4" />
|
||||
</div>
|
||||
<span>Item Calculator</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onClick={resetHistory} title="Reset History" class="p-2.5 hover:bg-destructive/10 hover:text-destructive rounded-xl text-muted-foreground/60 transition-all active:scale-90">
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setIsOpen(false)} class="p-2.5 hover:bg-muted rounded-xl text-muted-foreground transition-all active:scale-90">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display */}
|
||||
<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-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-border w-full justify-end' : ''}`}>
|
||||
<Show when={entry.label}>
|
||||
<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>
|
||||
<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)}
|
||||
</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-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>
|
||||
)}
|
||||
</For>
|
||||
<Show when={operator()}>
|
||||
<div class="px-3 py-1 rounded-xl text-[10px] font-black bg-primary text-primary-foreground shadow-lg shadow-primary/20 self-end mt-2 animate-bounce">{operator()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Controls */}
|
||||
<div class="p-6 grid grid-cols-4 gap-3 bg-card">
|
||||
<button onClick={clearInput} class="col-span-2 p-4 bg-destructive/5 text-destructive rounded-2xl font-black text-[10px] uppercase tracking-widest hover:bg-destructive/10 transition-all flex items-center justify-center gap-3 active:scale-95 shadow-sm">
|
||||
<Trash2 class="w-4 h-4" /> Clear Input
|
||||
</button>
|
||||
<button onClick={() => handleOperatorClick('/')} class={`p-4 rounded-2xl font-black transition-all active:scale-95 border-2 ${operator() === '/' ? 'bg-primary border-primary text-primary-foreground shadow-lg shadow-primary/20' : 'bg-card border-border text-muted-foreground hover:bg-muted'}`}>
|
||||
<Divide class="w-5 h-5 mx-auto" />
|
||||
</button>
|
||||
<button onClick={() => handleOperatorClick('*')} class={`p-4 rounded-2xl font-black transition-all active:scale-95 border-2 ${operator() === '*' ? 'bg-primary border-primary text-primary-foreground shadow-lg shadow-primary/20' : 'bg-card border-border text-muted-foreground hover:bg-muted'}`}>
|
||||
<Asterisk class="w-5 h-5 mx-auto" />
|
||||
</button>
|
||||
|
||||
<button onClick={createMarker} class="col-span-2 p-4 bg-muted/40 border border-border/60 rounded-2xl hover:bg-muted transition-all flex items-center justify-center gap-3 text-[10px] font-black uppercase tracking-widest text-muted-foreground active:scale-95 shadow-sm">
|
||||
<Flag class="w-4 h-4 text-primary" /> Create Marker
|
||||
</button>
|
||||
<button onClick={() => handleOperatorClick('-')} class={`p-4 rounded-2xl font-black transition-all active:scale-95 border-2 ${operator() === '-' ? 'bg-primary border-primary text-primary-foreground shadow-lg shadow-primary/20' : 'bg-card border-border text-muted-foreground hover:bg-muted'}`}>
|
||||
<Minus class="w-5 h-5 mx-auto" />
|
||||
</button>
|
||||
<button onClick={() => handleOperatorClick('+')} class={`p-4 rounded-2xl font-black transition-all active:scale-95 border-2 ${operator() === '+' ? 'bg-primary border-primary text-primary-foreground shadow-lg shadow-primary/20' : 'bg-card border-border text-muted-foreground hover:bg-muted'}`}>
|
||||
<Plus class="w-5 h-5 mx-auto" />
|
||||
</button>
|
||||
|
||||
<button onClick={handleEquals} class="col-span-4 p-5 bg-primary text-primary-foreground rounded-[2rem] font-black hover:bg-primary/90 transition-all flex items-center justify-center shadow-xl shadow-primary/20 active:scale-[0.98] border-2 border-primary/20 group">
|
||||
<Equal class="w-8 h-8 group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drop Zone Info */}
|
||||
<div class="px-8 py-4 bg-primary/5 text-[9px] font-black text-primary uppercase tracking-[0.3em] text-center border-t border-primary/10">
|
||||
{isHovered() ? 'Release to add field' : 'Drag item fields here to calculate'}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<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
|
||||
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>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default CalculatorPopover;
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createSignal, Show, For, createEffect } from 'solid-js';
|
||||
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;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
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) {
|
||||
loadCloudEstimatesList();
|
||||
}
|
||||
});
|
||||
|
||||
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',
|
||||
fields: 'id,name,created,updated'
|
||||
});
|
||||
setCloudEstimates(records);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to list estimates.');
|
||||
} finally {
|
||||
setIsCloudLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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(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 (
|
||||
<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">
|
||||
<h3 class="text-xl font-black">Load from Cloud</h3>
|
||||
<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 overflow-y-auto p-4 flex flex-col">
|
||||
<Show
|
||||
when={!isCloudLoading()}
|
||||
fallback={<div class="py-12 text-center text-muted-foreground">Loading estimates...</div>}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<For each={cloudEstimates()}>
|
||||
{(record) => (
|
||||
<div class="group flex items-center justify-between p-4 rounded-2xl border border-border/60 bg-muted/10 hover:bg-primary/5 hover:border-primary/30 transition-all">
|
||||
<div>
|
||||
<h4 class="font-bold text-foreground mb-1">{record.name}</h4>
|
||||
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
|
||||
{new Date(record.created).toLocaleDateString()}
|
||||
</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 disabled:opacity-50"
|
||||
>
|
||||
{loadingRecordId() === record.id ? 'Fetching...' : 'Load'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={cloudEstimates().length === 0}>
|
||||
<div class="py-12 text-center text-muted-foreground/60 border-2 border-dashed border-border/60 rounded-xl bg-muted/5">
|
||||
No saved estimates found.
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloudLoadModal;
|
||||
@@ -0,0 +1,674 @@
|
||||
import type { Component } 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';
|
||||
import NoteEditor from './NoteEditor';
|
||||
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';
|
||||
import { useSelectionManager } from './estimate-builder/useSelectionManager';
|
||||
import { useDragAndDrop } from './estimate-builder/useDragAndDrop';
|
||||
import { useEstimateSync } from './estimate-builder/useEstimateSync';
|
||||
|
||||
import { BuilderHeader } from './estimate-builder/BuilderHeader';
|
||||
import { BuilderSidebar } from './estimate-builder/BuilderSidebar';
|
||||
import { ProjectDetailsForm } from './estimate-builder/ProjectDetailsForm';
|
||||
import { UnassignedItemsSection } from './estimate-builder/UnassignedItemsSection';
|
||||
import { ExecutiveSummary } from './estimate-builder/ExecutiveSummary';
|
||||
import { SelectionToolbar } from './estimate-builder/SelectionToolbar';
|
||||
import { ValidationStatusBar } from './estimate-builder/ValidationStatusBar';
|
||||
import { ScopesList } from './estimate-builder/ScopesList';
|
||||
|
||||
interface EstimateBuilderProps {
|
||||
initialItems: ExtractedItem[];
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false);
|
||||
const [isNotesExpanded, setIsNotesExpanded] = createSignal(true);
|
||||
const [isPreNotesExpanded, setIsPreNotesExpanded] = createSignal(true);
|
||||
|
||||
// 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();
|
||||
const { grandTotals, validationStats, selectionTotals } = useEstimateCalculations(selectedIds);
|
||||
|
||||
const updateItem = (id: string, updates: Partial<EstimateItem>) => {
|
||||
setItems(item => item.id === id, updates);
|
||||
};
|
||||
|
||||
const { hoveredScopeId, setHoveredScopeId, handleDragStart, handleDrop } = useDragAndDrop(selectedIds, setSelectedIds, updateItem);
|
||||
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 => ({
|
||||
id: item.id,
|
||||
description: item.description,
|
||||
quantity: parseFloat(item.quantity.replace(/,/g, '')) || 0,
|
||||
unitPrice: 0,
|
||||
markup: 0,
|
||||
markupType: 'percent',
|
||||
contingency: 0,
|
||||
contingencyType: 'percent'
|
||||
}));
|
||||
setItems(transformed);
|
||||
}
|
||||
});
|
||||
|
||||
const unassignedItems = createMemo(() => items.filter(i => !i.scopeId));
|
||||
const anyDescriptionIsLong = createMemo(() => items.some(item => (item.description || '').length > 120));
|
||||
|
||||
const finalizeBid = () => {
|
||||
runPrint('finalized');
|
||||
};
|
||||
|
||||
const exportManagerInfo = () => {
|
||||
runPrint('manager');
|
||||
};
|
||||
|
||||
const runPrint = (mode: PrintMode) => {
|
||||
setPrintMode(mode);
|
||||
window.requestAnimationFrame(() => {
|
||||
window.print();
|
||||
window.setTimeout(() => setPrintMode('finalized'), 0);
|
||||
});
|
||||
};
|
||||
|
||||
const addScope = () => {
|
||||
const scopeId = crypto.randomUUID();
|
||||
const newScope: Scope = {
|
||||
id: scopeId,
|
||||
name: 'New Scope',
|
||||
managementFee: 0,
|
||||
managementFeeType: 'percent',
|
||||
managementFeeQuantity: 1,
|
||||
managementFeeRate: 0,
|
||||
deliveryFee: 0,
|
||||
deliveryFeeType: 'percent',
|
||||
deliveryFeeQuantity: 1,
|
||||
deliveryFeeRate: 0,
|
||||
useManagementLogic: 'percent',
|
||||
useDeliveryLogic: 'percent',
|
||||
estimatorNotes: '',
|
||||
bidDescription: '**Material and labor to install #**\n**Includes:**\n- #\n**Does Not Include:**\n- #'
|
||||
};
|
||||
setScopes(prev => [...prev, newScope]);
|
||||
addSubScopeWithName(scopeId, 'Materials');
|
||||
addSubScopeWithName(scopeId, 'Labor');
|
||||
return scopeId;
|
||||
};
|
||||
|
||||
const addSubScopeWithName = (scopeId: string, name: string) => {
|
||||
const newSubScope: SubScope = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
scopeId
|
||||
};
|
||||
setSubScopes(prev => [...prev, newSubScope]);
|
||||
return newSubScope.id;
|
||||
};
|
||||
|
||||
const addSubScope = (scopeId: string) => {
|
||||
addSubScopeWithName(scopeId, 'New Sub-scope');
|
||||
};
|
||||
|
||||
const deleteItem = (id: string) => {
|
||||
setItems(items.filter(i => i.id !== id));
|
||||
setSelectedIds(prev => prev.filter(selId => selId !== id));
|
||||
if (lastSelectedId() === id) setLastSelectedId(null);
|
||||
};
|
||||
|
||||
const duplicateItem = (id: string) => {
|
||||
const itemIndex = items.findIndex(i => i.id === id);
|
||||
if (itemIndex !== -1) {
|
||||
const itemToClone = items[itemIndex];
|
||||
const newItem: EstimateItem = { ...itemToClone, id: crypto.randomUUID() };
|
||||
const newItems = [...items];
|
||||
newItems.splice(itemIndex + 1, 0, newItem);
|
||||
setItems(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
const copySubScopeItems = (sourceSubScopeId: string, targetSubScopeId: string) => {
|
||||
const targetSubScope = subScopes.find(ss => ss.id === targetSubScopeId);
|
||||
if (!targetSubScope) return;
|
||||
const itemsToClone = items.filter(i => i.subScopeId === sourceSubScopeId);
|
||||
const newClonedItems = itemsToClone.map(item => ({
|
||||
...item, id: crypto.randomUUID(), subScopeId: targetSubScopeId, scopeId: targetSubScope.scopeId
|
||||
}));
|
||||
setItems(prev => [...prev, ...newClonedItems]);
|
||||
};
|
||||
|
||||
const updateScope = (id: string, updates: Partial<Scope>) => {
|
||||
setScopes(s => s.id === id, updates);
|
||||
};
|
||||
|
||||
const updateSubScope = (id: string, updates: Partial<SubScope>) => {
|
||||
setSubScopes(ss => ss.id === id, updates);
|
||||
};
|
||||
|
||||
const deleteScope = (id: string) => {
|
||||
setScopes(scopes.filter(s => s.id !== id));
|
||||
setItems(i => i.scopeId === id, { scopeId: undefined, subScopeId: undefined });
|
||||
setSubScopes(subScopes.filter(ss => ss.scopeId !== id));
|
||||
};
|
||||
|
||||
const deleteSubScope = (id: string) => {
|
||||
setSubScopes(subScopes.filter(ss => ss.id !== id));
|
||||
setItems(i => i.subScopeId === id, { subScopeId: undefined });
|
||||
};
|
||||
|
||||
const handleBulkUpdate = (scopeId: string, subScopeId: string | undefined, updates: any) => {
|
||||
setItems(item => item.scopeId === scopeId && (subScopeId === undefined || item.subScopeId === subScopeId), updates);
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
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}
|
||||
jobInfo={jobInfo}
|
||||
generalEstimateNotes={generalEstimateNotes()}
|
||||
generalPreNotes={generalPreNotes()}
|
||||
formatNumber={formatNumber}
|
||||
hideColumns={anyDescriptionIsLong()}
|
||||
variant={printMode()}
|
||||
/>
|
||||
|
||||
<div class="no-print flex flex-col min-h-dvh">
|
||||
<BuilderHeader
|
||||
isSidebarCollapsed={isSidebarCollapsed()}
|
||||
onToggleSidebar={() => setIsSidebarCollapsed(!isSidebarCollapsed())}
|
||||
onAddScope={addScope}
|
||||
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
|
||||
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()}
|
||||
setHoveredScopeId={setHoveredScopeId}
|
||||
onDrop={handleDrop}
|
||||
onAddScope={addScope}
|
||||
/>
|
||||
|
||||
<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">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3 text-foreground">
|
||||
<div class="p-2 bg-background rounded-xl shadow-sm">
|
||||
<FileText class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-black text-lg">General Pre-Notes</h3>
|
||||
<p class="text-muted-foreground text-[10px] font-bold uppercase tracking-wider">Project-wide preamble & terms</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsPreNotesExpanded(!isPreNotesExpanded())}
|
||||
class="p-2 hover:bg-background rounded-xl transition-all text-muted-foreground"
|
||||
>
|
||||
<ChevronDown class={`w-5 h-5 transition-transform ${isPreNotesExpanded() ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={isPreNotesExpanded()}>
|
||||
<div class="p-6 pt-0">
|
||||
<NoteEditor
|
||||
label="Pre-Scope Terms"
|
||||
value={generalPreNotes()}
|
||||
onUpdate={setGeneralPreNotes}
|
||||
icon={FileText}
|
||||
highlight={generalPreNotes().includes('#')}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<UnassignedItemsSection
|
||||
unassignedItems={unassignedItems()}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
anyDescriptionIsLong={anyDescriptionIsLong}
|
||||
selectedIds={selectedIds()}
|
||||
onDrop={handleDrop}
|
||||
clearSelection={clearSelection}
|
||||
updateItem={updateItem}
|
||||
deleteItem={deleteItem}
|
||||
handleDragStart={handleDragStart}
|
||||
duplicateItem={duplicateItem}
|
||||
toggleSelection={toggleSelection}
|
||||
/>
|
||||
|
||||
<ScopesList
|
||||
scopes={scopes}
|
||||
subScopes={subScopes}
|
||||
items={items}
|
||||
isNotesExpanded={isNotesExpanded()}
|
||||
setIsNotesExpanded={setIsNotesExpanded}
|
||||
generalEstimateNotes={generalEstimateNotes()}
|
||||
setGeneralEstimateNotes={setGeneralEstimateNotes}
|
||||
updateScope={updateScope}
|
||||
deleteScope={deleteScope}
|
||||
updateItem={updateItem}
|
||||
deleteItem={deleteItem}
|
||||
addSubScope={addSubScope}
|
||||
updateSubScope={updateSubScope}
|
||||
deleteSubScope={deleteSubScope}
|
||||
handleDragStart={handleDragStart}
|
||||
handleDrop={handleDrop}
|
||||
handleBulkUpdate={handleBulkUpdate}
|
||||
duplicateItem={duplicateItem}
|
||||
copySubScopeItems={copySubScopeItems}
|
||||
setApplyTemplateScopeId={setApplyTemplateScopeId}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
selectedIds={selectedIds()}
|
||||
toggleSelection={toggleSelection}
|
||||
clearSelection={clearSelection}
|
||||
anyDescriptionIsLong={anyDescriptionIsLong}
|
||||
addScope={addScope}
|
||||
/>
|
||||
|
||||
<ValidationStatusBar validationStats={validationStats()} />
|
||||
|
||||
<ExecutiveSummary totals={grandTotals()} onFinalizeBid={finalizeBid} onExportManagerInfo={exportManagerInfo} />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<CalculatorPopover />
|
||||
</div>
|
||||
|
||||
<SelectionToolbar
|
||||
selectedIds={selectedIds()}
|
||||
selectionTotals={selectionTotals()}
|
||||
onClearSelection={() => { setSelectedIds([]); setLastSelectedId(null); }}
|
||||
onCopyColumnSum={(col) => copyColumnSum(col, selectionTotals()[col], setCopyToast)}
|
||||
/>
|
||||
|
||||
<Show when={copyToast()}>
|
||||
<div class="fixed bottom-6 right-6 bg-gray-900 border border-gray-800 text-white px-4 py-3 rounded-xl shadow-2xl flex items-center gap-3 animate-in slide-in-from-bottom-5 z-50 pointer-events-none fade-out-90 duration-300">
|
||||
<CheckCircle2 class="w-5 h-5 text-green-400" />
|
||||
<span class="font-medium text-sm drop-shadow-sm">{copyToast()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={showSaveModal()}>
|
||||
<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-md rounded-3xl shadow-premium border border-border p-6 animate-in zoom-in-95 duration-200">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-black">Save to Cloud</h3>
|
||||
<button onClick={() => setShowSaveModal(false)} class="p-2 hover:bg-muted rounded-xl transition-colors">
|
||||
<X class="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
id="cloud-save-name"
|
||||
placeholder="Estimate Name (e.g. Smith Master Bath)"
|
||||
class="w-full bg-muted/30 border border-border/60 rounded-xl px-4 py-3 text-sm focus:bg-background focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all font-medium"
|
||||
value={appStore.estimateName()}
|
||||
/>
|
||||
<div class="flex gap-3 justify-end mt-6">
|
||||
<button onClick={() => setShowSaveModal(false)} class="px-5 py-2.5 rounded-xl font-bold text-sm hover:bg-muted transition-colors">Cancel</button>
|
||||
<button
|
||||
disabled={isCloudLoading()}
|
||||
onClick={() => {
|
||||
const name = (document.getElementById('cloud-save-name') as HTMLInputElement).value;
|
||||
if (name) saveToCloud(name);
|
||||
}}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
{isCloudLoading() ? 'Saving...' : 'Save Estimate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default EstimateBuilder;
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { Component } from 'solid-js';
|
||||
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;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AVAILABLE_COLUMNS = [
|
||||
{ id: 'scope', label: 'Scope' },
|
||||
{ id: 'subscope', label: 'Subscope' },
|
||||
{ id: 'description', label: 'Description' },
|
||||
{ id: 'quantity', label: 'Quantity' },
|
||||
{ id: 'unitPrice', label: 'Unit Price' },
|
||||
{ id: 'subtotal', label: 'Subtotal' },
|
||||
{ id: 'markup', label: 'Markup' },
|
||||
{ id: 'contingency', label: 'Contingency' },
|
||||
{ id: 'total', label: 'Total' }
|
||||
];
|
||||
|
||||
const ExportTableModal: Component<ExportTableModalProps> = (props) => {
|
||||
const scopes = () => appStore.scopes;
|
||||
const subScopes = () => appStore.subScopes;
|
||||
const items = () => appStore.estimateItems;
|
||||
|
||||
const [selectedScopeIds, setSelectedScopeIds] = createSignal<string[]>([]);
|
||||
const [selectedSubScopeIds, setSelectedSubScopeIds] = createSignal<string[]>([]);
|
||||
const [selectedColumns, setSelectedColumns] = createSignal<string[]>(AVAILABLE_COLUMNS.map(c => c.id));
|
||||
const [copyToast, setCopyToast] = createSignal(false);
|
||||
|
||||
// Initialize with all scopes and subscopes selected
|
||||
onMount(() => {
|
||||
if (props.show) {
|
||||
setSelectedScopeIds(scopes().map(s => s.id));
|
||||
setSelectedSubScopeIds(subScopes().map(s => s.id));
|
||||
}
|
||||
});
|
||||
|
||||
const toggleScope = (scopeId: string) => {
|
||||
const isSelected = selectedScopeIds().includes(scopeId);
|
||||
if (isSelected) {
|
||||
setSelectedScopeIds(prev => prev.filter(id => id !== scopeId));
|
||||
// Deselect all its subscopes
|
||||
const subscopeIds = subScopes().filter(ss => ss.scopeId === scopeId).map(ss => ss.id);
|
||||
setSelectedSubScopeIds(prev => prev.filter(id => !subscopeIds.includes(id)));
|
||||
} else {
|
||||
setSelectedScopeIds(prev => [...prev, scopeId]);
|
||||
// Select all its subscopes
|
||||
const subscopeIds = subScopes().filter(ss => ss.scopeId === scopeId).map(ss => ss.id);
|
||||
setSelectedSubScopeIds(prev => [...new Set([...prev, ...subscopeIds])]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSubScope = (subScopeId: string, scopeId: string) => {
|
||||
const isSelected = selectedSubScopeIds().includes(subScopeId);
|
||||
if (isSelected) {
|
||||
setSelectedSubScopeIds(prev => prev.filter(id => id !== subScopeId));
|
||||
|
||||
// If we deselected a subscope, also make sure the parent scope stays selected if we want, or deselect it if 0
|
||||
// Actually, keep it simple. Just toggle the subscope.
|
||||
} else {
|
||||
setSelectedSubScopeIds(prev => [...prev, subScopeId]);
|
||||
// If the parent scope isn't selected, select it
|
||||
if (!selectedScopeIds().includes(scopeId)) {
|
||||
setSelectedScopeIds(prev => [...prev, scopeId]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleColumn = (columnId: string) => {
|
||||
setSelectedColumns(prev =>
|
||||
prev.includes(columnId) ? prev.filter(id => id !== columnId) : [...prev, columnId]
|
||||
);
|
||||
};
|
||||
|
||||
const generateTableData = () => {
|
||||
const dataRows: Record<string, any>[] = [];
|
||||
|
||||
// Filter items that belong to the selected scopes and subscopes (or unassigned if we handled it, but let's stick to assigned)
|
||||
// Also handle scope-level items (no subscope) if the scope is selected
|
||||
const filteredItems = items().filter(item => {
|
||||
if (item.subScopeId) {
|
||||
return selectedSubScopeIds().includes(item.subScopeId);
|
||||
} else if (item.scopeId) {
|
||||
return selectedScopeIds().includes(item.scopeId);
|
||||
}
|
||||
return false; // ignore unassigned for this specific scope/subscope export
|
||||
});
|
||||
|
||||
filteredItems.forEach(item => {
|
||||
const scope = scopes().find(s => s.id === item.scopeId);
|
||||
const subScope = subScopes().find(ss => ss.id === item.subScopeId);
|
||||
const pricing = getEffectiveItemPricing(item, subScope?.modifiers || []);
|
||||
|
||||
dataRows.push({
|
||||
scope: scope?.name || 'Unassigned',
|
||||
subscope: subScope?.name || 'Scope Level',
|
||||
description: item.description,
|
||||
quantity: pricing.effectiveQuantity,
|
||||
unitPrice: pricing.effectiveUnitPrice,
|
||||
subtotal: pricing.effectiveSubtotal,
|
||||
markup: pricing.markupAmount,
|
||||
contingency: pricing.contingencyAmount,
|
||||
total: pricing.total
|
||||
});
|
||||
});
|
||||
|
||||
return dataRows;
|
||||
};
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
const data = generateTableData();
|
||||
const cols = AVAILABLE_COLUMNS.filter(c => selectedColumns().includes(c.id));
|
||||
|
||||
let tsv = cols.map(c => c.label).join('\t') + '\n';
|
||||
data.forEach(row => {
|
||||
tsv += cols.map(c => {
|
||||
const val = row[c.id];
|
||||
if (typeof val === 'number') {
|
||||
// For quantities don't add $ sign typically, but we will rely on excel to format it or just give raw numbers
|
||||
return ['unitPrice', 'subtotal', 'markup', 'contingency', 'total'].includes(c.id)
|
||||
? `$${val.toFixed(2)}`
|
||||
: val.toString();
|
||||
}
|
||||
return String(val || '').replace(/\t/g, ' ');
|
||||
}).join('\t') + '\n';
|
||||
});
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(tsv);
|
||||
setCopyToast(true);
|
||||
setTimeout(() => setCopyToast(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy', err);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadCSV = () => {
|
||||
const data = generateTableData();
|
||||
const cols = AVAILABLE_COLUMNS.filter(c => selectedColumns().includes(c.id));
|
||||
|
||||
let csv = cols.map(c => c.label).join(',') + '\n';
|
||||
data.forEach(row => {
|
||||
csv += cols.map(c => {
|
||||
const val = row[c.id];
|
||||
const strVal = typeof val === 'number' && ['unitPrice', 'subtotal', 'markup', 'contingency', 'total'].includes(c.id)
|
||||
? `$${val.toFixed(2)}`
|
||||
: String(val || '');
|
||||
return `"${strVal.replace(/"/g, '""')}"`;
|
||||
}).join(',') + '\n';
|
||||
});
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `exported-table.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
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-4xl 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-primary/10 text-primary rounded-xl">
|
||||
<Table class="w-5 h-5" />
|
||||
</div>
|
||||
<h3 class="text-xl font-black">Export Table Output</h3>
|
||||
</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 overflow-y-auto p-6 flex flex-col md:flex-row gap-8">
|
||||
{/* Scopes & Subscopes Column */}
|
||||
<div class="flex-1 space-y-4">
|
||||
<h4 class="text-xs font-black text-muted-foreground uppercase tracking-widest">Include Scopes & Subscopes</h4>
|
||||
<div class="space-y-3 bg-muted/10 p-4 rounded-2xl border border-border/50 max-h-[50vh] overflow-y-auto">
|
||||
<For each={scopes()}>
|
||||
{(scope) => (
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-3 cursor-pointer group" onClick={(e) => { e.preventDefault(); toggleScope(scope.id); }}>
|
||||
<div class={`w-5 h-5 rounded-md border flex items-center justify-center transition-colors ${selectedScopeIds().includes(scope.id) ? 'bg-primary border-primary text-primary-foreground' : 'border-border group-hover:border-primary/50'}`}>
|
||||
<Show when={selectedScopeIds().includes(scope.id)}>
|
||||
<CheckCircle2 class="w-3.5 h-3.5" />
|
||||
</Show>
|
||||
</div>
|
||||
<span class="font-bold">{scope.name}</span>
|
||||
</label>
|
||||
|
||||
<div class="pl-8 space-y-2 border-l border-border/50 ml-2.5">
|
||||
<For each={subScopes().filter(ss => ss.scopeId === scope.id)}>
|
||||
{(subScope) => (
|
||||
<label class="flex items-center gap-3 cursor-pointer group" onClick={(e) => { e.preventDefault(); toggleSubScope(subScope.id, scope.id); }}>
|
||||
<div class={`w-4 h-4 rounded-[4px] border flex items-center justify-center transition-colors ${selectedSubScopeIds().includes(subScope.id) ? 'bg-primary border-primary text-primary-foreground' : 'border-border group-hover:border-primary/50'}`}>
|
||||
<Show when={selectedSubScopeIds().includes(subScope.id)}>
|
||||
<CheckCircle2 class="w-3 h-3" />
|
||||
</Show>
|
||||
</div>
|
||||
<span class="text-sm text-foreground/80">{subScope.name}</span>
|
||||
</label>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={scopes().length === 0}>
|
||||
<div class="text-sm text-muted-foreground italic">No scopes available.</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Columns Column */}
|
||||
<div class="flex-1 space-y-4">
|
||||
<h4 class="text-xs font-black text-muted-foreground uppercase tracking-widest">Include Columns</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 bg-muted/10 p-4 rounded-2xl border border-border/50">
|
||||
<For each={AVAILABLE_COLUMNS}>
|
||||
{(col) => (
|
||||
<label class="flex items-center gap-3 cursor-pointer group" onClick={(e) => { e.preventDefault(); toggleColumn(col.id); }}>
|
||||
<div class={`w-5 h-5 rounded-md border flex items-center justify-center transition-colors ${selectedColumns().includes(col.id) ? 'bg-primary border-primary text-primary-foreground' : 'border-border group-hover:border-primary/50'}`}>
|
||||
<Show when={selectedColumns().includes(col.id)}>
|
||||
<CheckCircle2 class="w-3.5 h-3.5" />
|
||||
</Show>
|
||||
</div>
|
||||
<span class="font-bold text-sm">{col.label}</span>
|
||||
</label>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-border flex justify-end gap-3 bg-muted/5">
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
class="flex items-center gap-2 px-6 py-2.5 bg-background text-foreground border border-border rounded-xl text-sm font-bold shadow-sm hover:border-primary/50 hover:text-primary transition-all relative"
|
||||
>
|
||||
<Copy class="w-4 h-4" /> Copy to Clipboard
|
||||
<Show when={copyToast()}>
|
||||
<div class="absolute -top-10 left-1/2 -translate-x-1/2 bg-foreground text-background text-xs px-3 py-1 rounded-lg font-bold shadow-lg animate-in slide-in-from-bottom-2">
|
||||
Copied!
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
<button
|
||||
onClick={downloadCSV}
|
||||
class="flex items-center gap-2 px-6 py-2.5 bg-primary text-primary-foreground rounded-xl text-sm font-bold shadow-lg shadow-primary/20 hover:bg-primary/90 transition-all"
|
||||
>
|
||||
<Download class="w-4 h-4" /> Download CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportTableModal;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { UploadCloud } from 'lucide-solid';
|
||||
|
||||
interface FileUploadProps {
|
||||
onFileSelect: (text: string) => void;
|
||||
}
|
||||
|
||||
const FileUpload: Component<FileUploadProps> = (props) => {
|
||||
const handleFileChange = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string;
|
||||
props.onFileSelect(text);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex items-center justify-center w-full">
|
||||
<label
|
||||
for="dropzone-file"
|
||||
class="flex flex-col items-center justify-center w-full h-48 border-2 border-primary/20 border-dashed rounded-[2rem] cursor-pointer bg-muted/30 hover:bg-primary/5 transition-all group shadow-inner"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<div class="p-4 bg-card rounded-2xl shadow-premium mb-4 group-hover:scale-110 transition-transform duration-500">
|
||||
<UploadCloud class="w-10 h-10 text-primary" />
|
||||
</div>
|
||||
<p class="mb-2 text-sm text-foreground font-black tracking-tight">
|
||||
<span class="text-primary underline underline-offset-4 decoration-2">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p class="text-[10px] font-bold text-muted-foreground uppercase tracking-[0.2em]">CSV files only</p>
|
||||
</div>
|
||||
<input
|
||||
id="dropzone-file"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUpload;
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import { ChevronDown, ChevronRight, AlertCircle } from 'lucide-solid';
|
||||
|
||||
interface IgnoredLinesListProps {
|
||||
lines: string[][];
|
||||
}
|
||||
|
||||
const IgnoredLinesList: Component<IgnoredLinesListProps> = (props) => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
|
||||
return (
|
||||
<div class="bg-card rounded-3xl shadow-premium border border-border overflow-hidden transition-all hover:shadow-elevated">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen())}
|
||||
class="w-full flex items-center justify-between p-6 hover:bg-muted/30 transition-colors text-left"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-muted rounded-2xl shadow-sm">
|
||||
<AlertCircle class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-black text-lg text-foreground tracking-tight">Ignored CSV Rows ({props.lines.length})</h3>
|
||||
<p class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider mt-0.5">Lines that didn't match the extraction rules</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted-foreground/40">
|
||||
<Show when={isOpen()} fallback={<ChevronRight class="w-5 h-5" />}>
|
||||
<ChevronDown class="w-5 h-5 transition-transform duration-300 rotate-180" />
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<div class="border-t border-border bg-muted/20 p-6 max-h-[500px] overflow-y-auto custom-scrollbar">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="text-[10px] font-black uppercase tracking-[0.2em] text-muted-foreground/60 border-b border-border/60">
|
||||
<th class="pb-3 px-3">Line</th>
|
||||
<th class="pb-3 px-3">Raw Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-xs text-foreground font-medium">
|
||||
<For each={props.lines}>
|
||||
{(row, index) => (
|
||||
<tr class="border-b border-border/40 last:border-0 hover:bg-card/50 transition-colors group">
|
||||
<td class="py-3 px-3 font-mono text-muted-foreground/40 w-16 text-[10px]">{index() + 1}</td>
|
||||
<td class="py-3 px-3 font-mono break-all leading-relaxed text-muted-foreground group-hover:text-foreground">
|
||||
{row.join(' | ')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-6 flex items-center gap-3 justify-center px-4 py-3 bg-card/50 rounded-2xl border border-border/40 shadow-inner">
|
||||
<AlertCircle class="w-4 h-4 text-muted-foreground/40" />
|
||||
<p class="text-[10px] font-bold text-muted-foreground/60 uppercase tracking-wider italic">
|
||||
These lines were skipped because they don't follow the "Item : Description (Qty)" or "Item : Description" pattern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IgnoredLinesList;
|
||||
@@ -0,0 +1,537 @@
|
||||
import type { Component } 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, Modifier, StandardPrice } from '../types';
|
||||
import { pb, COLLECTIONS } from '../utils/db';
|
||||
import { getEffectiveItemPricing } from '../utils/pricing';
|
||||
|
||||
interface ItemRowProps {
|
||||
item: EstimateItem;
|
||||
onUpdate: (id: string, updates: Partial<EstimateItem>) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onDragStart: (e: DragEvent, id: string) => void;
|
||||
isSidebarCollapsed: () => boolean;
|
||||
isSelected: boolean;
|
||||
anySelected: boolean;
|
||||
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 pricing = createMemo(() => getEffectiveItemPricing(props.item, props.subScopeModifiers || []));
|
||||
const subtotal = createMemo(() => pricing().effectiveSubtotal);
|
||||
const total = createMemo(() => pricing().total);
|
||||
|
||||
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);
|
||||
|
||||
createEffect(() => {
|
||||
if (!isEditing()) {
|
||||
setLocalDescription(props.item.description);
|
||||
}
|
||||
});
|
||||
|
||||
const handleBlur = () => {
|
||||
const currentDesc = localDescription();
|
||||
if (currentDesc !== props.item.description) {
|
||||
props.onUpdate(props.item.id, { description: currentDesc });
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
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 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' : 'rounded-2xl'}
|
||||
`}
|
||||
>
|
||||
{/* ── DESKTOP ROW ── */}
|
||||
<div class="hidden md:flex items-center gap-4 px-3 py-2">
|
||||
|
||||
{/* 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);
|
||||
}}
|
||||
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>
|
||||
|
||||
{/* 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="qty"
|
||||
value={props.item.quantity}
|
||||
onUpdate={(val) => props.onUpdate(props.item.id, { quantity: val })}
|
||||
onDragStart={(e) => {
|
||||
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';
|
||||
e.stopPropagation();
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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.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" />
|
||||
</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>
|
||||
|
||||
{/* ── 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>
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default ItemRow;
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Component, JSX } from 'solid-js';
|
||||
import { createSignal, onMount, onCleanup, Show } from 'solid-js';
|
||||
|
||||
interface LazyItemProps {
|
||||
children: JSX.Element;
|
||||
estimatedHeight?: number;
|
||||
rootMargin?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that only renders its children when it's within (or near) the viewport.
|
||||
* This is a lightweight alternative to full list virtualization.
|
||||
*/
|
||||
const LazyItem: Component<LazyItemProps> = (props) => {
|
||||
const [isVisible, setIsVisible] = createSignal(false);
|
||||
let containerRef: HTMLDivElement | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
setIsVisible(entry.isIntersecting);
|
||||
}, {
|
||||
rootMargin: props.rootMargin || '400px 0px', // Pre-render 400px before appearing
|
||||
threshold: 0
|
||||
});
|
||||
|
||||
if (containerRef) {
|
||||
observer.observe(containerRef);
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
observer.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="lazy-item-container"
|
||||
style={{
|
||||
"min-height": isVisible() ? "auto" : `${props.estimatedHeight || 60}px`,
|
||||
"contain-intrinsic-size": `auto ${props.estimatedHeight || 60}px`,
|
||||
"content-visibility": "auto" // CSS-native optimization hint
|
||||
}}
|
||||
>
|
||||
<Show when={isVisible()}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LazyItem;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For, Show, createSignal } from 'solid-js';
|
||||
import { Eye, Edit3 } from 'lucide-solid';
|
||||
|
||||
interface NoteEditorProps {
|
||||
value: string;
|
||||
onUpdate: (value: string) => void;
|
||||
label: string;
|
||||
icon: any;
|
||||
placeholder?: string;
|
||||
highlight?: boolean;
|
||||
headerActions?: any;
|
||||
}
|
||||
|
||||
const NoteEditor: Component<NoteEditorProps> = (props) => {
|
||||
const [isEditing, setIsEditing] = createSignal(false);
|
||||
|
||||
const renderMarkdown = (text: string) => {
|
||||
if (!text) return <span class="text-muted-foreground italic font-medium opacity-50">No notes yet...</span>;
|
||||
|
||||
const lines = text.split('\n');
|
||||
return (
|
||||
<div class="space-y-1">
|
||||
<For each={lines}>
|
||||
{(line) => {
|
||||
let processed = line;
|
||||
// Basic Bold
|
||||
processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
// Basic Italics
|
||||
processed = processed.replace(/(\*|_)(.*?)\1/g, '<em>$2</em>');
|
||||
|
||||
// Basic Bullet Points
|
||||
if (processed.trim().startsWith('- ')) {
|
||||
return <li class="ml-4 list-disc text-foreground/80" innerHTML={processed.substring(2)} />;
|
||||
}
|
||||
|
||||
return <p class="min-h-[1.2em] text-foreground/90" innerHTML={processed || ' '} />;
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleInput = (e: any) => {
|
||||
props.onUpdate(e.currentTarget.value);
|
||||
// Auto-resize
|
||||
e.currentTarget.style.height = 'auto';
|
||||
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={`rounded-2xl border transition-all duration-300 overflow-hidden flex flex-col ${props.highlight ? 'border-destructive/30 bg-destructive/5 shadow-premium ring-1 ring-destructive/10' : 'bg-card border-border shadow-sm'}`}>
|
||||
<div class={`px-4 py-2 border-b flex items-center justify-between ${props.highlight ? 'bg-destructive/10 border-destructive/20' : 'bg-muted/30 border-border/40'}`}>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class={props.highlight ? 'text-destructive' : 'text-muted-foreground'}>
|
||||
<props.icon class="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<span class={`text-[10px] font-black uppercase tracking-widest ${props.highlight ? 'text-destructive' : 'text-muted-foreground'}`}>{props.label}</span>
|
||||
</div>
|
||||
<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
|
||||
when={isEditing()}
|
||||
fallback={
|
||||
<div class="text-sm text-foreground/90 prose prose-sm max-w-none prose-headings:text-foreground prose-strong:text-foreground">
|
||||
{renderMarkdown(props.value)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<textarea
|
||||
value={props.value}
|
||||
onInput={handleInput}
|
||||
class="w-full min-h-[100px] bg-transparent border-none outline-none text-sm text-foreground/90 resize-none font-sans"
|
||||
style={{ "field-sizing": "content" }}
|
||||
placeholder={props.placeholder || "Type notes here... (- for bullets, **bold**, _italic_)"}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteEditor;
|
||||
@@ -0,0 +1,386 @@
|
||||
import type { Component, Accessor } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
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: {
|
||||
name: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
fax: string;
|
||||
};
|
||||
jobInfo: JobInfo;
|
||||
generalPreNotes: string;
|
||||
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;
|
||||
let scopeContingency = 0;
|
||||
|
||||
const scope = props.scopes.find((s: Scope) => s.id === scopeId);
|
||||
if (!scope) return 0;
|
||||
|
||||
props.items.filter((i: EstimateItem) => i.scopeId === scopeId).forEach((item: EstimateItem) => {
|
||||
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'
|
||||
? scopeSubtotal * (scope.managementFee / 100)
|
||||
: scope.managementFee;
|
||||
|
||||
const deliveryFee = scope.useDeliveryLogic === 'percent'
|
||||
? scopeSubtotal * (scope.deliveryFee / 100)
|
||||
: scope.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 */}
|
||||
<div class="flex justify-between items-start mb-6 pb-4 border-b-2 border-foreground">
|
||||
<div class="space-y-0.5">
|
||||
<h1 class="text-xl font-black text-foreground uppercase tracking-tight">{COMPANY_NAME}</h1>
|
||||
<div class="text-muted-foreground font-medium text-xs flex flex-col">
|
||||
<span>{COMPANY_ADDRESS}</span>
|
||||
<span>{COMPANY_PHONE}</span>
|
||||
<Show when={COMPANY_EMAIL}><span>{COMPANY_EMAIL}</span></Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Client & Project Information Grid */}
|
||||
<div class="grid grid-cols-2 gap-8 mb-8">
|
||||
<div>
|
||||
<h3 class="text-[10px] font-bold text-muted-foreground uppercase tracking-wider border-b border-border pb-1 mb-2">Client Information</h3>
|
||||
<div class="pl-1">
|
||||
<p class="font-bold text-foreground text-sm">{props.clientInfo.name || '---'}</p>
|
||||
<p class="text-muted-foreground text-xs">{props.clientInfo.address || '---'}</p>
|
||||
<div class="flex gap-3 text-[11px] text-muted-foreground/80 mt-0.5">
|
||||
<Show when={props.clientInfo.phone}><span>P: {props.clientInfo.phone}</span></Show>
|
||||
<Show when={props.clientInfo.fax}><span>F: {props.clientInfo.fax}</span></Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<Show when={props.jobInfo.workOrder}>
|
||||
<div class="flex gap-2 text-xs">
|
||||
<span class="font-bold text-muted-foreground/60 w-16 uppercase text-[10px] pt-0.5">WO No.</span>
|
||||
<span class="font-bold text-foreground">{props.jobInfo.workOrder}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex gap-2 text-xs">
|
||||
<span class="font-bold text-muted-foreground/60 w-16 uppercase text-[10px] pt-0.5">Location</span>
|
||||
<span class="font-medium text-foreground">{props.jobInfo.address || '---'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* General Pre-Notes */}
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Scope Details Table */}
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
</Show>
|
||||
|
||||
{/* General Estimate Notes */}
|
||||
<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">
|
||||
{props.generalEstimateNotes}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Final Section: Totals & Signature aligned */}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrintEstimate;
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { Copy, Check, CheckCircle2 } from 'lucide-solid';
|
||||
import type { ExtractedItem } from '../utils/csv-parser';
|
||||
|
||||
interface ResultsTableProps {
|
||||
items: ExtractedItem[];
|
||||
checkedIds: Set<string>;
|
||||
copiedParts: Record<string, { desc: boolean, qty: boolean }>;
|
||||
onCopy: (id: string, text: string, type: 'desc' | 'qty') => void;
|
||||
onToggleCheck: (id: string) => void;
|
||||
}
|
||||
|
||||
const ResultsTable: Component<ResultsTableProps> = (props) => {
|
||||
return (
|
||||
<div class="max-w-4xl w-full">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden border border-gray-100">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-gray-800 text-white">
|
||||
<th class="p-4 w-12 text-center"></th>
|
||||
<th class="p-4 w-2/3 font-semibold text-sm">
|
||||
Description <span class="text-[10px] font-normal text-gray-400 ml-2">(Click to copy)</span>
|
||||
</th>
|
||||
<th class="p-4 w-1/4 font-semibold text-sm text-center">
|
||||
Quantity <span class="text-[10px] font-normal text-gray-400 ml-2">(Click to copy)</span>
|
||||
</th>
|
||||
<th class="p-4 w-12 text-center text-[10px] font-normal text-gray-400">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-700">
|
||||
<For each={props.items}>
|
||||
{(item, index) => {
|
||||
const isChecked = () => props.checkedIds.has(item.id);
|
||||
const isDescCopied = () => props.copiedParts[item.id]?.desc;
|
||||
const isQtyCopied = () => props.copiedParts[item.id]?.qty;
|
||||
const isFullyCopied = () => isDescCopied() && isQtyCopied();
|
||||
|
||||
return (
|
||||
<tr
|
||||
class={`transition-all duration-200 ${index() % 2 === 0 ? 'bg-white' : 'bg-gray-50'
|
||||
} ${isChecked() ? 'opacity-40 grayscale-[0.5]' : 'opacity-100'} border-b border-gray-100 hover:bg-blue-50/30`}
|
||||
>
|
||||
<td class="p-4 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isChecked()}
|
||||
onChange={() => props.onToggleCheck(item.id)}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
class="p-4 border-r border-gray-100 cursor-pointer relative group"
|
||||
onClick={() => props.onCopy(item.id, item.description, 'desc')}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class={`transition-colors text-sm ${isDescCopied() ? 'text-gray-400' : 'text-gray-800'}`}>
|
||||
{item.description}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={isDescCopied()}>
|
||||
<Check class="w-3 h-3 text-green-500" />
|
||||
</Show>
|
||||
<Copy class="w-4 h-4 text-gray-300 group-hover:text-blue-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="p-4 cursor-pointer group text-center"
|
||||
onClick={() => props.onCopy(item.id, item.quantity, 'qty')}
|
||||
>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<span class={`font-mono text-sm font-medium px-2 py-1 rounded transition-colors ${isQtyCopied()
|
||||
? 'bg-gray-100 text-gray-400 font-normal'
|
||||
: 'bg-blue-50 text-blue-600'
|
||||
}`}>
|
||||
{item.quantity}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Show when={isQtyCopied()}>
|
||||
<Check class="w-3 h-3 text-green-500" />
|
||||
</Show>
|
||||
<Copy class="w-4 h-4 text-gray-300 group-hover:text-blue-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-4 text-center">
|
||||
<Show when={isFullyCopied()}>
|
||||
<CheckCircle2 class="w-5 h-5 text-green-600 inline-block" />
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResultsTable;
|
||||
@@ -0,0 +1,551 @@
|
||||
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, 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;
|
||||
subScopes: SubScope[];
|
||||
items: EstimateItem[];
|
||||
onUpdateScope: (id: string, updates: Partial<Scope>) => void;
|
||||
onDeleteScope: (id: string) => void;
|
||||
onUpdateItem: (id: string, updates: Partial<EstimateItem>) => void;
|
||||
onDeleteItem: (id: string) => void;
|
||||
onAddSubScope: (scopeId: string) => void;
|
||||
onUpdateSubScope: (id: string, updates: Partial<SubScope>) => void;
|
||||
onDeleteSubScope: (id: string) => void;
|
||||
onDragStart: (e: DragEvent, id: string) => void;
|
||||
onDrop: (e: DragEvent, scopeId: string, subScopeId?: string) => void;
|
||||
onBulkUpdate: (scopeId: string, subScopeId: string | undefined, updates: { markup?: number, markupType?: PricingType, contingency?: number, contingencyType?: PricingType }) => void;
|
||||
onDuplicateItem: (id: string) => void;
|
||||
allScopes: Scope[];
|
||||
allSubScopes: SubScope[];
|
||||
onCopySubScopeItems: (sourceSubScopeId: string, targetSubScopeId: string) => void;
|
||||
onOpenApplyTemplate: (scopeId: string) => void;
|
||||
isSidebarCollapsed: () => boolean;
|
||||
selectedIds: string[];
|
||||
onToggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void;
|
||||
onClearSelection: () => void;
|
||||
hideColumns?: boolean;
|
||||
}
|
||||
|
||||
|
||||
const ScopeCard: Component<ScopeCardProps> = (props) => {
|
||||
const [showBulkActions, setShowBulkActions] = createSignal(false);
|
||||
const [isCollapsed, setIsCollapsed] = createSignal(false);
|
||||
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(() => {
|
||||
const scopeLevelTotals = getItemsPricingTotals(scopeItems(), []);
|
||||
let subtotal = scopeLevelTotals.subtotal;
|
||||
let markup = scopeLevelTotals.markup;
|
||||
let contingency = scopeLevelTotals.contingency;
|
||||
let totalQuantity = scopeLevelTotals.quantity;
|
||||
|
||||
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 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;
|
||||
|
||||
const deliveryTotal = props.scope.useDeliveryLogic === 'percent'
|
||||
? subtotal * (props.scope.deliveryFee / 100)
|
||||
: props.scope.deliveryFee;
|
||||
|
||||
return {
|
||||
subtotal,
|
||||
markup,
|
||||
contingency,
|
||||
mgmtTotal,
|
||||
deliveryTotal,
|
||||
modifiersTotal,
|
||||
totalQuantity,
|
||||
total: subtotal + markup + contingency + mgmtTotal + deliveryTotal + modifiersTotal
|
||||
};
|
||||
});
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const formatQuantity = (num: number) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = 'move';
|
||||
setIsOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
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}
|
||||
onDrop={(e) => {
|
||||
setIsOver(false);
|
||||
props.onDrop(e, props.scope.id);
|
||||
}}
|
||||
onDragLeave={handleDragLeave}
|
||||
class={`bg-muted/30 border rounded-3xl overflow-visible transition-all ring-1 ring-border/50 shadow-premium
|
||||
${isOver() ? 'bg-primary/5 ring-4 ring-primary/10 border-primary' : 'border-border'}
|
||||
`}
|
||||
>
|
||||
{/* Scope Header - Rounded Top */}
|
||||
<div class="p-6 bg-card border-b border-border flex items-center justify-between rounded-t-3xl">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed())}
|
||||
class="p-2 hover:bg-muted rounded-lg transition-colors text-muted-foreground group"
|
||||
>
|
||||
<ChevronDown class={`w-4 h-4 transition-transform duration-200 ${isCollapsed() ? '-rotate-90' : ''}`} />
|
||||
</button>
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
type="text"
|
||||
value={props.scope.name}
|
||||
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-[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">
|
||||
<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())}
|
||||
class="p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-all"
|
||||
>
|
||||
<Settings2 class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.onDeleteScope(props.scope.id)}
|
||||
class="p-2 text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-all"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!isCollapsed()}>
|
||||
<Show when={showBulkActions()}>
|
||||
<div class="bg-primary/5 border-b border-primary/10 p-4 flex flex-wrap gap-4 items-center animate-in slide-in-from-top duration-300">
|
||||
<span class="text-sm font-semibold text-primary">Bulk Adjust Scope:</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs text-primary/70">Markup %:</label>
|
||||
<NumericInput
|
||||
class="w-16 px-2 py-1 border border-primary/20 bg-background rounded text-sm focus:ring-2 focus:ring-primary outline-none"
|
||||
value={bulkMarkup()}
|
||||
onUpdate={(val) => setBulkMarkup(val)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => props.onBulkUpdate(props.scope.id, undefined, { markup: bulkMarkup(), markupType: 'percent' })}
|
||||
class="px-2 py-1 bg-primary text-primary-foreground text-xs rounded-lg font-bold hover:bg-primary/90"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 border-l border-primary/20 pl-4">
|
||||
<label class="text-xs text-primary/70">Contingency %:</label>
|
||||
<NumericInput
|
||||
class="w-16 px-2 py-1 border border-primary/20 bg-background rounded text-sm focus:ring-2 focus:ring-primary outline-none"
|
||||
value={bulkContingency()}
|
||||
onUpdate={(val) => setBulkContingency(val)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => props.onBulkUpdate(props.scope.id, undefined, { contingency: bulkContingency(), contingencyType: 'percent' })}
|
||||
class="px-2 py-1 bg-primary text-primary-foreground text-xs rounded-lg font-bold hover:bg-primary/90"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<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">
|
||||
<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}
|
||||
activeSupplier={props.scope.supplier}
|
||||
/>
|
||||
</LazyItem>
|
||||
)}
|
||||
</For>
|
||||
</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 — 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
|
||||
subScope={subScope}
|
||||
items={props.items.filter(i => i.subScopeId === subScope.id)}
|
||||
onUpdateSubScope={props.onUpdateSubScope}
|
||||
onUpdateItem={props.onUpdateItem}
|
||||
onDeleteItem={props.onDeleteItem}
|
||||
onDeleteSubScope={props.onDeleteSubScope}
|
||||
onDragStart={props.onDragStart}
|
||||
onDrop={(e, sid, ssid) => props.onDrop(e, sid, ssid)}
|
||||
onDuplicateItem={props.onDuplicateItem}
|
||||
allScopes={props.allScopes}
|
||||
allSubScopes={props.allSubScopes}
|
||||
onCopySubScopeItems={props.onCopySubScopeItems}
|
||||
isSidebarCollapsed={props.isSidebarCollapsed}
|
||||
selectedIds={props.selectedIds}
|
||||
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-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">
|
||||
<span class="text-sm font-medium text-muted-foreground">Management Fee</span>
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => props.onUpdateScope(props.scope.id, { useManagementLogic: 'percent' })}
|
||||
class={`p-1 rounded-md transition-colors ${props.scope.useManagementLogic === 'percent' ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}
|
||||
>
|
||||
<Percent class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
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">
|
||||
{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"
|
||||
/>
|
||||
) : (
|
||||
<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 */}
|
||||
<div class="flex items-center justify-between group">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-muted-foreground">Delivery Fee</span>
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => props.onUpdateScope(props.scope.id, { useDeliveryLogic: 'percent' })}
|
||||
class={`p-1 rounded-md transition-colors ${props.scope.useDeliveryLogic === 'percent' ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}
|
||||
>
|
||||
<Percent class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
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">
|
||||
{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"
|
||||
/>
|
||||
) : (
|
||||
<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 */}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 pt-8 border-t border-border">
|
||||
<NoteEditor
|
||||
label="Estimator's Notes"
|
||||
value={props.scope.estimatorNotes || ''}
|
||||
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)"
|
||||
value={props.scope.bidDescription || ''}
|
||||
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>
|
||||
|
||||
{/* Scope Totals Breakdown */}
|
||||
<div class="space-y-1 text-sm pt-4">
|
||||
<div class="flex justify-end gap-8 text-muted-foreground italic">
|
||||
<span>Scope Subtotal:</span>
|
||||
<span class="w-28 text-right">${formatNumber(totals().subtotal)}</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-8 text-muted-foreground italic">
|
||||
<span>Scope Total Quantity:</span>
|
||||
<span class="w-28 text-right">{formatQuantity(totals().totalQuantity)}</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-8 text-primary">
|
||||
<span>Scope Markup:</span>
|
||||
<span class="w-28 text-right">${formatNumber(totals().markup)}</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-8 text-orange-600">
|
||||
<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">
|
||||
${formatNumber(totals().total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<ScopeTextTemplateModal
|
||||
show={isTemplateModalOpen()}
|
||||
templateType={activeTemplateType()}
|
||||
currentValue={templateModalCurrentValue()}
|
||||
onApply={applyTemplateValue}
|
||||
onClose={() => setIsTemplateModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScopeCard;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,272 @@
|
||||
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;
|
||||
items: EstimateItem[];
|
||||
onUpdateSubScope: (id: string, updates: Partial<SubScope>) => void;
|
||||
onUpdateItem: (id: string, updates: Partial<EstimateItem>) => void;
|
||||
onDeleteItem: (id: string) => void;
|
||||
onDeleteSubScope: (id: string) => void;
|
||||
onDragStart: (e: DragEvent, id: string) => void;
|
||||
onDrop: (e: DragEvent, scopeId: string, subScopeId: string) => void;
|
||||
onDuplicateItem: (id: string) => void;
|
||||
allScopes: Scope[];
|
||||
allSubScopes: SubScope[];
|
||||
onCopySubScopeItems: (sourceSubScopeId: string, targetSubScopeId: string) => void;
|
||||
isSidebarCollapsed: () => boolean;
|
||||
selectedIds: string[];
|
||||
onToggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void;
|
||||
onClearSelection: () => void;
|
||||
hideColumns?: boolean;
|
||||
scopeSupplier?: string;
|
||||
}
|
||||
|
||||
const SubScopeCard: Component<SubScopeCardProps> = (props) => {
|
||||
const [isCollapsed, setIsCollapsed] = createSignal(false);
|
||||
const [isOver, setIsOver] = createSignal(false);
|
||||
const [showCopyMenu, setShowCopyMenu] = createSignal(false);
|
||||
|
||||
const subScopeItems = () => props.items.filter(item => item.subScopeId === props.subScope.id);
|
||||
|
||||
const totals = createMemo(() => {
|
||||
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) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const formatQuantity = (num: number) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOver(false);
|
||||
props.onDrop(e, props.subScope.scopeId, props.subScope.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`subscope-${props.subScope.id}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
class={`border rounded-xl transition-all duration-200 ml-8
|
||||
${isOver()
|
||||
? 'bg-primary/5 border-primary ring-4 ring-primary/10 z-10'
|
||||
: 'bg-card border-border hover:border-border/50 shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div class="px-4 py-3 flex items-center justify-between border-b border-border/40">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed())}
|
||||
class="p-1 hover:bg-muted rounded transition-colors text-muted-foreground"
|
||||
>
|
||||
<ChevronDown class={`w-4 h-4 transition-transform duration-200 ${isCollapsed() ? '-rotate-90' : ''}`} />
|
||||
</button>
|
||||
<div class="p-1.5 bg-muted rounded-lg text-muted-foreground">
|
||||
<ListTree class="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={props.subScope.name}
|
||||
onInput={(e) => props.onUpdateSubScope(props.subScope.id, { name: e.currentTarget.value })}
|
||||
class="bg-transparent border-none outline-none font-black text-foreground text-sm focus:ring-0 w-48"
|
||||
/>
|
||||
<div class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
<span>{props.items.length} Items</span>
|
||||
<span>•</span>
|
||||
<span class="text-muted-foreground/60">{formatQuantity(totals().quantity)} Qty</span>
|
||||
<span>•</span>
|
||||
<span class="text-primary font-black">${formatNumber(totals().amount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="relative">
|
||||
<button
|
||||
onClick={() => setShowCopyMenu(!showCopyMenu())}
|
||||
class="flex items-center gap-1.5 px-2 py-1 bg-background border border-border rounded-lg text-[10px] font-black uppercase tracking-wider text-muted-foreground hover:text-primary hover:border-primary/30 transition-all"
|
||||
title="Copy items to another sub-scope"
|
||||
>
|
||||
<span class="p-0.5 bg-muted rounded">
|
||||
<Copy class="w-2.5 h-2.5" />
|
||||
</span>
|
||||
Copy To
|
||||
</button>
|
||||
|
||||
<Show when={showCopyMenu()}>
|
||||
<div
|
||||
class="absolute right-0 mt-1 w-64 bg-popover border border-border rounded-xl shadow-elevated z-[100] py-2 animate-in fade-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div class="px-4 py-2 border-b border-border/40 mb-1">
|
||||
<span class="text-[9px] font-black text-muted-foreground uppercase tracking-[0.2em]">Select Target Sub-scope</span>
|
||||
</div>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<For each={props.allScopes}>
|
||||
{(scope) => (
|
||||
<div class="px-2">
|
||||
<div class="px-2 py-1 text-[9px] font-black text-primary/50 uppercase tracking-widest">{scope.name}</div>
|
||||
<For each={props.allSubScopes.filter(ss => ss.scopeId === scope.id && ss.id !== props.subScope.id)}>
|
||||
{(targetSS) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
props.onCopySubScopeItems(props.subScope.id, targetSS.id);
|
||||
setShowCopyMenu(false);
|
||||
}}
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-xs font-bold text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors flex items-center justify-between group"
|
||||
>
|
||||
<span>{targetSS.name}</span>
|
||||
<ChevronDown class="w-3 h-3 opacity-0 group-hover:opacity-40 -rotate-90" />
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => props.onDeleteSubScope(props.subScope.id)}
|
||||
class="p-1.5 text-muted-foreground/30 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!isCollapsed()}>
|
||||
<div
|
||||
onClick={(e) => { if (e.target === e.currentTarget) props.onClearSelection(); }}
|
||||
class="relative"
|
||||
>
|
||||
{/* SubScope Column Headers */}
|
||||
<Show when={props.items.length > 0}>
|
||||
<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>
|
||||
<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 px-2">Total</div>
|
||||
<div class="w-16 shrink-0"></div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubScopeCard;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { Show } from 'solid-js';
|
||||
|
||||
interface ToastProps {
|
||||
show: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const Toast: Component<ToastProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.show}>
|
||||
<div
|
||||
class="fixed bottom-12 left-1/2 -translate-x-1/2 min-w-[250px] bg-gray-800 text-white text-center rounded-lg p-4 shadow-lg transition-all transform animate-bounce-in z-50"
|
||||
>
|
||||
{props.message}
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createSignal, onCleanup, Show } from 'solid-js';
|
||||
import type { Component } from 'solid-js';
|
||||
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;
|
||||
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">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-black text-foreground tracking-tight leading-none">Estimate <span class="text-primary">Builder</span></h2>
|
||||
<div class="flex items-center gap-2 text-muted-foreground text-[10px] font-bold uppercase tracking-wider mt-0.5">
|
||||
<span>{scopes.length} Scopes</span>
|
||||
<span class="text-border">•</span>
|
||||
<span>{items.length} Items</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
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 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-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-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 New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For } from 'solid-js';
|
||||
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;
|
||||
setHoveredScopeId: (id: string | null) => void;
|
||||
onDrop: (e: DragEvent, scopeId?: string, subScopeId?: string) => void;
|
||||
onAddScope: () => void;
|
||||
}
|
||||
|
||||
export const BuilderSidebar: Component<BuilderSidebarProps> = (props) => {
|
||||
const { scopes, subScopes, estimateItems: items } = appStore;
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
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-[var(--spacing-header)] bg-background border-r border-border flex flex-col overflow-visible z-40 transition-colors"
|
||||
style={{ "min-width": 0 }}
|
||||
>
|
||||
{/*
|
||||
* 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>
|
||||
|
||||
{/*
|
||||
* 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Show, type Component } from 'solid-js';
|
||||
import { Info, Plus } from 'lucide-solid';
|
||||
|
||||
interface ExecutiveSummaryProps {
|
||||
totals: {
|
||||
subtotal: number;
|
||||
markup: number;
|
||||
contingency: number;
|
||||
fees: number;
|
||||
modifiersTotal: number;
|
||||
grandTotal: number;
|
||||
};
|
||||
onFinalizeBid: () => void;
|
||||
onExportManagerInfo: () => void;
|
||||
}
|
||||
|
||||
export const ExecutiveSummary: Component<ExecutiveSummaryProps> = (props) => {
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="mt-20 p-10 bg-muted/30 rounded-[3rem] border border-border shadow-inner">
|
||||
<div class="max-w-7xl mx-auto flex flex-col lg:flex-row gap-10 items-center lg:items-end justify-between">
|
||||
<div class="flex-1 w-full space-y-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="px-4 py-1.5 bg-primary/10 text-primary rounded-full text-[10px] font-black uppercase tracking-[0.2em] border border-primary/20 shadow-sm">
|
||||
Executive Summary
|
||||
</div>
|
||||
<div class="flex-1 h-px bg-border/60"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<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">Project Subtotal</span>
|
||||
<div class="text-2xl xl:text-3xl font-black text-foreground tracking-tight truncate">${formatNumber(props.totals.subtotal)}</div>
|
||||
</div>
|
||||
<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">Total Markup</span>
|
||||
<div class="text-2xl xl:text-3xl font-black text-primary tracking-tight truncate">${formatNumber(props.totals.markup)}</div>
|
||||
</div>
|
||||
<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">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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4 px-6 py-4 bg-primary/5 rounded-2xl border border-primary/10 shadow-sm">
|
||||
<Info class="w-5 h-5 text-primary/60 shrink-0 mt-0.5" />
|
||||
<p class="text-xs font-medium text-primary/70 leading-relaxed">
|
||||
Comprehensive project total including all active scopes, sub-scopes, and unassigned takeoff items.
|
||||
Pricing is calculated dynamically based on current material/labor allocations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 text-center lg:text-right group bg-card p-10 rounded-[3rem] border border-primary/10 shadow-elevated min-w-[340px] lg:min-w-[380px] max-w-full transition-all hover:shadow-2xl">
|
||||
<div class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.4em] mb-3 group-hover:text-primary transition-colors">Total Estimate Value</div>
|
||||
<div class="text-5xl sm:text-6xl xl:text-7xl font-black text-foreground tracking-tighter transition-all group-hover:scale-[1.02] duration-500 break-words drop-shadow-sm">
|
||||
<span class="text-2xl sm:text-3xl text-primary font-medium align-top mr-1">$</span>
|
||||
{formatNumber(props.totals.grandTotal)}
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { FolderKanban } from 'lucide-solid';
|
||||
import { appStore } from '../../store/appStore';
|
||||
|
||||
export const ProjectDetailsForm: Component = () => {
|
||||
const { clientInfo, setClientInfo, jobInfo, setJobInfo } = appStore;
|
||||
|
||||
return (
|
||||
<div class="bg-card border border-border rounded-[2.5rem] p-6 md:p-8 shadow-premium transition-all w-full max-w-full overflow-hidden">
|
||||
<div class="flex items-center gap-3 text-foreground mb-8">
|
||||
<div class="p-2.5 bg-primary/10 text-primary rounded-2xl shadow-sm">
|
||||
<FolderKanban class="w-5 h-5" />
|
||||
</div>
|
||||
<h3 class="font-black text-xl tracking-tight">Client & Project Details</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 lg:gap-x-12 gap-y-8 w-full">
|
||||
<div class="space-y-6">
|
||||
<h4 class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em] px-1 blur-[0.2px]">Client Information</h4>
|
||||
<div class="grid gap-3">
|
||||
<input type="text" value={clientInfo.name} onInput={(e) => setClientInfo('name', e.currentTarget.value)} class="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" placeholder="Client Name" />
|
||||
<input type="text" value={clientInfo.address} onInput={(e) => setClientInfo('address', e.currentTarget.value)} class="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" placeholder="Client Address" />
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<input type="text" value={clientInfo.phone} onInput={(e) => setClientInfo('phone', e.currentTarget.value)} class="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 flex-1 min-w-0" placeholder="Phone" />
|
||||
<input type="text" value={clientInfo.fax} onInput={(e) => setClientInfo('fax', e.currentTarget.value)} class="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 flex-1 min-w-0" placeholder="Fax" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h4 class="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em] px-1 blur-[0.2px]">Project Information</h4>
|
||||
<div class="grid gap-3">
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<input type="text" value={jobInfo.number} onInput={(e) => setJobInfo('number', e.currentTarget.value)} class="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 flex-1 min-w-0" placeholder="Job Number" />
|
||||
<input type="text" value={jobInfo.name} onInput={(e) => setJobInfo('name', e.currentTarget.value)} class="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 flex-1 min-w-0" placeholder="Job Name" />
|
||||
</div>
|
||||
<input type="text" value={jobInfo.workOrder} onInput={(e) => setJobInfo('workOrder', e.currentTarget.value)} class="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" placeholder="Work Order Number" />
|
||||
<input type="text" value={jobInfo.address} onInput={(e) => setJobInfo('address', e.currentTarget.value)} class="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" placeholder="Job Site Address" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { FolderKanban, Plus, FileText, ChevronDown } from 'lucide-solid';
|
||||
import ScopeCard from '../ScopeCard';
|
||||
import NoteEditor from '../NoteEditor';
|
||||
import type { Scope, SubScope, EstimateItem } from '../../types';
|
||||
|
||||
interface ScopesListProps {
|
||||
scopes: Scope[];
|
||||
subScopes: SubScope[];
|
||||
items: EstimateItem[];
|
||||
isNotesExpanded: boolean;
|
||||
setIsNotesExpanded: (expanded: boolean) => void;
|
||||
generalEstimateNotes: string;
|
||||
setGeneralEstimateNotes: (notes: string) => void;
|
||||
|
||||
updateScope: (id: string, updates: Partial<Scope>) => void;
|
||||
deleteScope: (id: string) => void;
|
||||
updateItem: (id: string, updates: Partial<EstimateItem>) => void;
|
||||
deleteItem: (id: string) => void;
|
||||
addSubScope: (scopeId: string) => void;
|
||||
updateSubScope: (id: string, updates: Partial<SubScope>) => void;
|
||||
deleteSubScope: (id: string) => void;
|
||||
handleDragStart: (e: DragEvent, id: string) => void;
|
||||
handleDrop: (e: DragEvent, scopeId?: string, subScopeId?: string) => void;
|
||||
handleBulkUpdate: (scopeId: string, subScopeId: string | undefined, updates: any) => void;
|
||||
duplicateItem: (id: string) => void;
|
||||
copySubScopeItems: (sourceSubScopeId: string, targetSubScopeId: string) => void;
|
||||
setApplyTemplateScopeId: (id: string | null) => void;
|
||||
isSidebarCollapsed: () => boolean;
|
||||
selectedIds: string[];
|
||||
toggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void;
|
||||
clearSelection: (e?: MouseEvent) => void;
|
||||
anyDescriptionIsLong: () => boolean;
|
||||
|
||||
addScope: () => void;
|
||||
}
|
||||
|
||||
export const ScopesList: Component<ScopesListProps> = (props) => {
|
||||
return (
|
||||
<div class="space-y-12">
|
||||
<For each={props.scopes}>
|
||||
{(scope) => (
|
||||
<div id={`scope-${scope.id}`}>
|
||||
<ScopeCard
|
||||
scope={scope}
|
||||
subScopes={props.subScopes.filter(ss => ss.scopeId === scope.id)}
|
||||
items={props.items.filter(i => i.scopeId === scope.id)}
|
||||
onUpdateScope={props.updateScope}
|
||||
onDeleteScope={props.deleteScope}
|
||||
onUpdateItem={props.updateItem}
|
||||
onDeleteItem={props.deleteItem}
|
||||
onAddSubScope={props.addSubScope}
|
||||
onUpdateSubScope={props.updateSubScope}
|
||||
onDeleteSubScope={props.deleteSubScope}
|
||||
onDragStart={props.handleDragStart}
|
||||
onDrop={props.handleDrop}
|
||||
onBulkUpdate={props.handleBulkUpdate}
|
||||
onDuplicateItem={props.duplicateItem}
|
||||
allScopes={props.scopes}
|
||||
allSubScopes={props.subScopes}
|
||||
onCopySubScopeItems={props.copySubScopeItems}
|
||||
onOpenApplyTemplate={props.setApplyTemplateScopeId}
|
||||
isSidebarCollapsed={props.isSidebarCollapsed}
|
||||
selectedIds={props.selectedIds}
|
||||
onToggleSelection={props.toggleSelection}
|
||||
onClearSelection={props.clearSelection}
|
||||
hideColumns={props.anyDescriptionIsLong()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={props.scopes.length > 0}>
|
||||
<div class="mt-8 bg-card border border-border rounded-3xl overflow-hidden shadow-premium transition-all duration-300">
|
||||
<button
|
||||
onClick={() => props.setIsNotesExpanded(!props.isNotesExpanded)}
|
||||
class="w-full flex items-center justify-between p-6 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-foreground">
|
||||
<div class="p-2 bg-muted rounded-xl shadow-sm">
|
||||
<FileText class="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<h3 class="font-black text-lg">General Estimate Notes</h3>
|
||||
<p class="text-muted-foreground text-xs font-bold uppercase tracking-wider">Configure standard project terms and exclusions</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class={`transition-transform duration-300 ${props.isNotesExpanded ? 'rotate-180' : ''}`}>
|
||||
<ChevronDown class="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class={`transition-all duration-300 ease-in-out ${props.isNotesExpanded ? 'max-h-[2000px] border-t border-gray-50' : 'max-h-0'}`}>
|
||||
<div class="p-6 pt-2">
|
||||
<NoteEditor
|
||||
label="Inclusions, Exclusions & Terms"
|
||||
value={props.generalEstimateNotes}
|
||||
onUpdate={props.setGeneralEstimateNotes}
|
||||
icon={FileText}
|
||||
placeholder="Add project-wide notes here..."
|
||||
highlight={props.generalEstimateNotes.includes('#')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.scopes.length === 0}>
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center space-y-6">
|
||||
<div class="p-8 bg-muted rounded-full text-muted-foreground/30 shadow-inner">
|
||||
<FolderKanban class="w-16 h-16" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-2xl font-black text-foreground tracking-tight">No Scopes Defined</h3>
|
||||
<p class="text-muted-foreground max-w-sm mx-auto font-medium">Click "New Scope" in the header to start building your estimate structure.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={props.addScope}
|
||||
class="flex items-center gap-2 px-8 py-4 bg-primary text-primary-foreground rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-primary/90 transition-all shadow-xl shadow-primary/20 active:scale-95"
|
||||
>
|
||||
<Plus class="w-5 h-5" /> Start First Scope
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { X } from 'lucide-solid';
|
||||
|
||||
interface SelectionToolbarProps {
|
||||
selectedIds: string[];
|
||||
selectionTotals: {
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
subtotal: number;
|
||||
markup: number;
|
||||
contingency: number;
|
||||
total: number;
|
||||
};
|
||||
onClearSelection: () => void;
|
||||
onCopyColumnSum: (column: 'quantity'|'unitPrice'|'subtotal'|'markup'|'contingency'|'total') => void;
|
||||
}
|
||||
|
||||
export const SelectionToolbar: Component<SelectionToolbarProps> = (props) => {
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.selectedIds.length >= 2}>
|
||||
<Portal>
|
||||
<div class="fixed bottom-8 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-3 bg-card/95 backdrop-blur-md text-foreground px-5 py-4 rounded-[2rem] shadow-elevated border border-border animate-in fade-in slide-in-from-bottom-6 duration-500">
|
||||
<div class="flex flex-col items-center justify-center pr-2 border-r border-border">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-primary mb-0.5">
|
||||
{props.selectedIds.length}
|
||||
</span>
|
||||
<span class="text-[8px] font-black uppercase tracking-tighter text-muted-foreground">Selected</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<For each={[
|
||||
{ key: 'quantity', label: 'Qty' },
|
||||
{ key: 'unitPrice', label: 'Price' },
|
||||
{ key: 'subtotal', label: 'Sub' },
|
||||
{ key: 'markup', label: 'Mark' },
|
||||
{ key: 'contingency', label: 'Cont' },
|
||||
{ key: 'total', label: 'Total' },
|
||||
] as const}>
|
||||
{(item) => (
|
||||
<button
|
||||
onClick={() => props.onCopyColumnSum(item.key)}
|
||||
class="flex flex-col items-center gap-0 px-4 py-2 rounded-2xl bg-muted/40 hover:bg-primary hover:text-primary-foreground text-foreground transition-all border border-border/40 hover:border-primary hover:-translate-y-1 group relative active:scale-95"
|
||||
>
|
||||
<span class="text-[8px] font-black uppercase tracking-tighter text-muted-foreground group-hover:text-primary-foreground/60 transition-colors">{item.label}</span>
|
||||
<span class="text-xs font-black mt-[-1px] tracking-tight">
|
||||
{item.key === 'quantity' ? props.selectionTotals[item.key] : `$${formatNumber(props.selectionTotals[item.key])}`}
|
||||
</span>
|
||||
{/* Simple hover tooltip hint */}
|
||||
<div class="absolute -top-8 left-1/2 -translate-x-1/2 bg-foreground text-background text-[8px] font-black px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none uppercase tracking-widest">
|
||||
Copy
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="w-px h-8 bg-border mx-1" />
|
||||
<button
|
||||
onClick={props.onClearSelection}
|
||||
class="p-3 rounded-2xl hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all active:scale-90"
|
||||
title="Clear selection"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Component, Accessor } from 'solid-js';
|
||||
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';
|
||||
|
||||
interface UnassignedItemsSectionProps {
|
||||
unassignedItems: EstimateItem[];
|
||||
isSidebarCollapsed: Accessor<boolean>;
|
||||
anyDescriptionIsLong: Accessor<boolean>;
|
||||
selectedIds: string[];
|
||||
onDrop: (e: DragEvent, scopeId?: string, subScopeId?: string) => void;
|
||||
clearSelection: (e: MouseEvent) => void;
|
||||
updateItem: (id: string, updates: Partial<EstimateItem>) => void;
|
||||
deleteItem: (id: string) => void;
|
||||
handleDragStart: (e: DragEvent, id: string) => void;
|
||||
duplicateItem: (id: string) => void;
|
||||
toggleSelection: (id: string, isMulti: boolean, isShift: boolean) => void;
|
||||
}
|
||||
|
||||
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
|
||||
id="unassigned-section"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => props.onDrop(e, undefined, undefined)}
|
||||
onClick={props.clearSelection}
|
||||
class="bg-muted/10 border-2 border-dashed border-border rounded-3xl p-6 transition-all hover:bg-muted/20"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3 text-foreground">
|
||||
<div class="p-2 bg-primary/10 rounded-xl text-primary shadow-sm">
|
||||
<ListTree class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-black text-lg tracking-tight">Unassigned Takeoff Items</h3>
|
||||
<p class="text-muted-foreground text-sm">Drag these items into scopes to begin your estimate.</p>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
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>
|
||||
<div class="w-24 shrink-0 text-right">Unit Price</div>
|
||||
<div class="w-24 shrink-0 text-right">Subtotal</div>
|
||||
<Show when={props.isSidebarCollapsed() || !props.anyDescriptionIsLong()}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { Show } from 'solid-js';
|
||||
import { AlertTriangle, CheckCircle2 } from 'lucide-solid';
|
||||
|
||||
interface ValidationStatusBarProps {
|
||||
validationStats: {
|
||||
hasIncomplete: boolean;
|
||||
incompleteCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const ValidationStatusBar: Component<ValidationStatusBarProps> = (props) => {
|
||||
return (
|
||||
<div class={`mt-12 p-5 rounded-3xl border shadow-premium transition-all duration-500 flex items-center justify-between ${props.validationStats.hasIncomplete ? 'bg-destructive/5 border-destructive/20 text-destructive' : 'bg-primary/5 border-primary/20 text-primary'}`}>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class={`p-3 rounded-2xl shadow-sm ${props.validationStats.hasIncomplete ? 'bg-destructive/20' : 'bg-primary/20'}`}>
|
||||
{props.validationStats.hasIncomplete ? <AlertTriangle class="w-5 h-5" /> : <CheckCircle2 class="w-5 h-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-black text-sm uppercase tracking-tight">
|
||||
{props.validationStats.hasIncomplete
|
||||
? `Attention Required: ${props.validationStats.incompleteCount} fields contain unfinalized information (#)`
|
||||
: "All notes have been updated."}
|
||||
</p>
|
||||
<p class={`text-[10px] font-bold uppercase tracking-widest opacity-80 mt-0.5`}>
|
||||
{props.validationStats.hasIncomplete
|
||||
? "Please replace all '#' markers with final project data before finalizing."
|
||||
: "Everything looks good to go!"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={!props.validationStats.hasIncomplete}>
|
||||
<div class="px-4 py-2 bg-primary text-primary-foreground rounded-xl text-[10px] font-black uppercase tracking-wider shadow-lg shadow-primary/20 transition-all">
|
||||
Ready to Print
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
|
||||
export function useDragAndDrop(
|
||||
selectedIds: () => string[],
|
||||
setSelectedIds: (ids: string[]) => void,
|
||||
updateItem: (id: string, updates: any) => void
|
||||
) {
|
||||
const [hoveredScopeId, setHoveredScopeId] = createSignal<string | null>(null);
|
||||
|
||||
const handleDragStart = (e: DragEvent, id: string) => {
|
||||
if (!selectedIds().includes(id)) {
|
||||
setSelectedIds([id]);
|
||||
}
|
||||
e.dataTransfer!.setData('text/plain', id);
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent, scopeId?: string, subScopeId?: string) => {
|
||||
e.preventDefault();
|
||||
setHoveredScopeId(null);
|
||||
|
||||
const idsToMove = selectedIds();
|
||||
if (idsToMove.length > 0) {
|
||||
idsToMove.forEach(id => {
|
||||
updateItem(id, { scopeId, subScopeId });
|
||||
});
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
return { hoveredScopeId, setHoveredScopeId, handleDragStart, handleDrop };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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;
|
||||
const scopes = appStore.scopes;
|
||||
const generalEstimateNotes = appStore.generalEstimateNotes;
|
||||
const generalPreNotes = appStore.generalPreNotes;
|
||||
|
||||
const grandTotals = createMemo(() => {
|
||||
let subtotal = 0;
|
||||
let markup = 0;
|
||||
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 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) + pricing.effectiveSubtotal);
|
||||
}
|
||||
});
|
||||
|
||||
scopes.forEach(scope => {
|
||||
const scopeSubtotal = scopeSubtotals.get(scope.id) || 0;
|
||||
|
||||
fees += scope.useManagementLogic === 'percent'
|
||||
? scopeSubtotal * (scope.managementFee / 100)
|
||||
: scope.managementFee;
|
||||
|
||||
fees += scope.useDeliveryLogic === 'percent'
|
||||
? scopeSubtotal * (scope.deliveryFee / 100)
|
||||
: 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,
|
||||
modifiersTotal,
|
||||
grandTotal: subtotal + markup + contingency + fees + modifiersTotal
|
||||
};
|
||||
});
|
||||
|
||||
const validationStats = createMemo(() => {
|
||||
let count = 0;
|
||||
if (generalPreNotes().includes('#')) count++;
|
||||
if (generalEstimateNotes().includes('#')) count++;
|
||||
|
||||
scopes.forEach(s => {
|
||||
if (s.bidDescription?.includes('#')) count++;
|
||||
if (s.estimatorNotes?.includes('#')) count++;
|
||||
});
|
||||
|
||||
return {
|
||||
hasIncomplete: count > 0,
|
||||
incompleteCount: count
|
||||
};
|
||||
});
|
||||
|
||||
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 pricing = getEffectiveItemPricing(item, item.subScopeId ? (subScopeById.get(item.subScopeId)?.modifiers || []) : []);
|
||||
|
||||
qty += pricing.effectiveQuantity;
|
||||
price += pricing.effectiveUnitPrice;
|
||||
sub += pricing.effectiveSubtotal;
|
||||
mark += pricing.markupAmount;
|
||||
cont += pricing.contingencyAmount;
|
||||
tot += pricing.total;
|
||||
});
|
||||
|
||||
return {
|
||||
quantity: Math.round(qty * 100) / 100,
|
||||
unitPrice: Math.round(price * 100) / 100,
|
||||
subtotal: Math.round(sub * 100) / 100,
|
||||
markup: Math.round(mark * 100) / 100,
|
||||
contingency: Math.round(cont * 100) / 100,
|
||||
total: Math.round(tot * 100) / 100
|
||||
};
|
||||
});
|
||||
|
||||
return { grandTotals, validationStats, selectionTotals };
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
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,
|
||||
setHistory,
|
||||
setIsLatestVersion,
|
||||
setLatestSnapshot,
|
||||
setEstimateId, setEstimateName
|
||||
} = appStore;
|
||||
|
||||
const exportEstimate = () => {
|
||||
const data = {
|
||||
scopes: [...scopes],
|
||||
subScopes: [...subScopes],
|
||||
items: [...items],
|
||||
clientInfo: { ...clientInfo },
|
||||
jobInfo: { ...jobInfo },
|
||||
generalEstimateNotes: generalEstimateNotes(),
|
||||
generalPreNotes: generalPreNotes(),
|
||||
version: '1.2'
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${jobInfo.number || '0000'}-${jobInfo.name || 'unnamed'}-${clientInfo.name || 'client'}-estimate.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const importEstimate = (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.target?.result as string);
|
||||
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);
|
||||
if (data.jobInfo) setJobInfo(data.jobInfo);
|
||||
if (data.generalEstimateNotes !== undefined) setGeneralEstimateNotes(data.generalEstimateNotes);
|
||||
if (data.generalNotes !== undefined) setGeneralEstimateNotes(data.generalNotes);
|
||||
if (data.generalPreNotes !== undefined) setGeneralPreNotes(data.generalPreNotes);
|
||||
} catch (err) {
|
||||
alert('Failed to parse estimate file.');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const getFullEstimateData = () => {
|
||||
return {
|
||||
scopes: [...scopes],
|
||||
subScopes: [...subScopes],
|
||||
items: [...items],
|
||||
clientInfo: { ...clientInfo },
|
||||
jobInfo: { ...jobInfo },
|
||||
generalEstimateNotes: generalEstimateNotes(),
|
||||
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();
|
||||
const record = await pb.collection(COLLECTIONS.ESTIMATES).create({
|
||||
name,
|
||||
items: { ...data, changeSummary: null },
|
||||
history: [],
|
||||
userId: pb.authStore.model?.id
|
||||
});
|
||||
|
||||
setShowSaveModal(false);
|
||||
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 collection permissions are public.');
|
||||
} finally {
|
||||
setIsCloudLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { appStore } from '../../store/appStore';
|
||||
import type { EstimateItem } from '../../types';
|
||||
|
||||
export function useSelectionManager() {
|
||||
const [selectedIds, setSelectedIds] = createSignal<string[]>([]);
|
||||
const [lastSelectedId, setLastSelectedId] = createSignal<string | null>(null);
|
||||
|
||||
const getVisualOrderedItems = () => {
|
||||
const { estimateItems: items, scopes, subScopes } = appStore;
|
||||
const ordered: EstimateItem[] = [];
|
||||
|
||||
ordered.push(...items.filter(i => !i.scopeId));
|
||||
|
||||
scopes.forEach(scope => {
|
||||
ordered.push(...items.filter(i => i.scopeId === scope.id && !i.subScopeId));
|
||||
subScopes.filter(ss => ss.scopeId === scope.id).forEach(ss => {
|
||||
ordered.push(...items.filter(i => i.subScopeId === ss.id));
|
||||
});
|
||||
});
|
||||
|
||||
return ordered;
|
||||
};
|
||||
|
||||
const toggleSelection = (id: string, isMulti: boolean, isShift: boolean = false) => {
|
||||
if (isShift && lastSelectedId()) {
|
||||
const orderedItems = getVisualOrderedItems();
|
||||
const lastIdx = orderedItems.findIndex(i => i.id === lastSelectedId());
|
||||
const currentIdx = orderedItems.findIndex(i => i.id === id);
|
||||
|
||||
if (lastIdx !== -1 && currentIdx !== -1) {
|
||||
const start = Math.min(lastIdx, currentIdx);
|
||||
const end = Math.max(lastIdx, currentIdx);
|
||||
const rangeIds = orderedItems.slice(start, end + 1).map(i => i.id);
|
||||
|
||||
setSelectedIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
rangeIds.forEach(rid => newSet.add(rid));
|
||||
return Array.from(newSet);
|
||||
});
|
||||
}
|
||||
} else if (isMulti) {
|
||||
setSelectedIds(prev => prev.includes(id)
|
||||
? prev.filter(i => i !== id)
|
||||
: [...prev, id]
|
||||
);
|
||||
} else {
|
||||
if (selectedIds().includes(id) && selectedIds().length === 1) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds([id]);
|
||||
}
|
||||
}
|
||||
setLastSelectedId(id);
|
||||
};
|
||||
|
||||
const clearSelection = (e?: MouseEvent) => {
|
||||
if (!e || e.target === e.currentTarget) {
|
||||
setSelectedIds([]);
|
||||
setLastSelectedId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const copyColumnSum = (column: 'quantity'|'unitPrice'|'subtotal'|'markup'|'contingency'|'total', val: number, setCopyToast: (msg: string | null) => void) => {
|
||||
navigator.clipboard.writeText(String(val)).then(() => {
|
||||
const labels: Record<string, string> = {
|
||||
quantity: 'Qty', unitPrice: 'Price', subtotal: 'Subtotal',
|
||||
markup: 'Markup', contingency: 'Contingency', total: 'Total'
|
||||
};
|
||||
setCopyToast(`Copied ${labels[column]}: ${val}`);
|
||||
setTimeout(() => setCopyToast(null), 2500);
|
||||
});
|
||||
};
|
||||
|
||||
return { selectedIds, setSelectedIds, lastSelectedId, setLastSelectedId, toggleSelection, clearSelection, copyColumnSum };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { Plus } from 'lucide-solid';
|
||||
import type { TemplateItem } from '../../types';
|
||||
import TemplateItemRow from './TemplateItemRow';
|
||||
|
||||
interface TemplateItemListProps {
|
||||
items: TemplateItem[];
|
||||
onUpdateItem: (id: string, updates: Partial<TemplateItem>) => void;
|
||||
onDeleteItem: (id: string) => void;
|
||||
onAddItem: () => void;
|
||||
}
|
||||
|
||||
const TemplateItemList: Component<TemplateItemListProps> = (props) => {
|
||||
return (
|
||||
<div class="mt-8 space-y-4">
|
||||
<div class="flex items-center justify-between pb-2 border-b border-border/60">
|
||||
<h3 class="text-lg font-semibold text-foreground/90">Template Items</h3>
|
||||
<button
|
||||
onClick={props.onAddItem}
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{/* Table Header */}
|
||||
<Show when={props.items.length > 0}>
|
||||
<div class="flex items-center gap-4 px-3 py-1 text-[10px] font-bold text-muted-foreground/50 uppercase tracking-wider">
|
||||
<div class="w-4 shrink-0"></div> {/* Grip Spacer */}
|
||||
<div class="flex-1">Description</div>
|
||||
<div class="w-24 shrink-0 text-right">Qty</div>
|
||||
<div class="w-32 shrink-0 text-right pr-6">Price</div>
|
||||
<div class="w-28 shrink-0 text-center">Markup</div>
|
||||
<div class="w-28 shrink-0 text-center">Cont</div>
|
||||
<div class="w-10 shrink-0"></div> {/* Actions Spacer */}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<TemplateItemRow
|
||||
item={item}
|
||||
onUpdate={props.onUpdateItem}
|
||||
onDelete={props.onDeleteItem}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
{props.items.length === 0 && (
|
||||
<div class="text-center py-12 text-muted-foreground/60 border-2 border-dashed border-border/60 rounded-xl bg-card">
|
||||
<p>No items added yet. Click "Add Item" to start building this template.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateItemList;
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createSignal, createEffect } from 'solid-js';
|
||||
import { Trash2, GripVertical } from 'lucide-solid';
|
||||
import NumericInput from '../ui/NumericInput';
|
||||
import type { TemplateItem } from '../../types';
|
||||
|
||||
interface TemplateItemRowProps {
|
||||
item: TemplateItem;
|
||||
onUpdate: (id: string, updates: Partial<TemplateItem>) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
const TemplateItemRow: Component<TemplateItemRowProps> = (props) => {
|
||||
let descriptionRef: HTMLTextAreaElement | undefined;
|
||||
const [isEditing, setIsEditing] = createSignal(false);
|
||||
const [localDescription, setLocalDescription] = createSignal(props.item.description);
|
||||
|
||||
createEffect(() => {
|
||||
if (!isEditing()) {
|
||||
setLocalDescription(props.item.description);
|
||||
}
|
||||
});
|
||||
|
||||
const handleBlur = () => {
|
||||
const currentDesc = localDescription();
|
||||
if (currentDesc !== props.item.description) {
|
||||
props.onUpdate(props.item.id, { description: currentDesc });
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<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="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 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateItemRow;
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { createSignal, Show, For, createEffect } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { X, FileBox, CheckCircle2 } from 'lucide-solid';
|
||||
import { pb, COLLECTIONS } from '../../utils/db';
|
||||
|
||||
interface TemplateLoadModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onLoad: (template: any) => void;
|
||||
}
|
||||
|
||||
const TemplateLoadModal: Component<TemplateLoadModalProps> = (props) => {
|
||||
const [templates, setTemplates] = createSignal<any[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
|
||||
createEffect(() => {
|
||||
if (props.show) {
|
||||
loadTemplates();
|
||||
}
|
||||
});
|
||||
|
||||
const loadTemplates = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const records = await pb.collection(COLLECTIONS.TEMPLATES).getFullList({
|
||||
sort: '-created'
|
||||
});
|
||||
setTemplates(records);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to load templates.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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-primary/10 text-primary rounded-xl">
|
||||
<FileBox class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-foreground">Load Template</h3>
|
||||
<p class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider mt-0.5">Select a template to edit</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-6 overflow-hidden">
|
||||
{/* 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">Saved Templates</label>
|
||||
<div class="flex-1 overflow-y-auto space-y-3 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>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
props.onLoad(record);
|
||||
props.onClose();
|
||||
}}
|
||||
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" /> Load
|
||||
</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" />
|
||||
<p class="text-sm font-medium">No saved templates found.</p>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateLoadModal;
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Component, JSX } from 'solid-js';
|
||||
import { createSignal, createEffect } from 'solid-js';
|
||||
|
||||
interface NumericInputProps extends Omit<JSX.InputHTMLAttributes<HTMLInputElement>, 'onInput' | 'value'> {
|
||||
value: number;
|
||||
onUpdate: (val: number) => void;
|
||||
dataFieldName?: string;
|
||||
class?: string;
|
||||
allowTextSearch?: boolean;
|
||||
onTextSearch?: (searchString: string) => void;
|
||||
onTextSearchExit?: () => void;
|
||||
inputRef?: any;
|
||||
}
|
||||
|
||||
const NumericInput: Component<NumericInputProps> = (props) => {
|
||||
const [isFocused, setIsFocused] = createSignal(false);
|
||||
const [localValue, setLocalValue] = createSignal(props.value.toString());
|
||||
|
||||
createEffect(() => {
|
||||
if (!isFocused() || (props.allowTextSearch && !localValue().startsWith('@') && props.value !== parseFloat(localValue()))) {
|
||||
setLocalValue(props.value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
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)) {
|
||||
// If invalid, revert to previous local value (simple way to block invalid keys)
|
||||
e.currentTarget.value = localValue();
|
||||
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
|
||||
if (val === '' || val === '-' || val === '.' || val === '-.') {
|
||||
props.onUpdate(0);
|
||||
} else {
|
||||
const parsed = parseFloat(val);
|
||||
if (!isNaN(parsed)) {
|
||||
props.onUpdate(parsed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const callHandler = (handler: any, event: any) => {
|
||||
if (typeof handler === 'function') {
|
||||
handler(event);
|
||||
} else if (Array.isArray(handler)) {
|
||||
handler[0](handler[1], event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = (e: FocusEvent) => {
|
||||
setIsFocused(true);
|
||||
// Select all text on focus to make it easier to replace values
|
||||
(e.currentTarget as HTMLInputElement).select();
|
||||
callHandler(props.onFocus, e);
|
||||
};
|
||||
|
||||
const handleBlur = (e: FocusEvent) => {
|
||||
setIsFocused(false);
|
||||
// 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"
|
||||
value={localValue()}
|
||||
onInput={handleInput}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
class={props.class}
|
||||
data-field-name={props.dataFieldName}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NumericInput;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
/* Import Standard Shadcn Font (Inter) */
|
||||
@theme {
|
||||
/* Clean, single-font family for a modern SaaS look */
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
/* Classic Shadcn Zinc Palette */
|
||||
--color-background: hsl(0 0% 100%);
|
||||
--color-foreground: hsl(240 10% 3.9%);
|
||||
|
||||
--color-card: hsl(0 0% 100%);
|
||||
--color-card-foreground: hsl(240 10% 3.9%);
|
||||
|
||||
--color-popover: hsl(0 0% 100%);
|
||||
--color-popover-foreground: hsl(240 10% 3.9%);
|
||||
|
||||
--color-primary: hsl(240 5.9% 10%);
|
||||
--color-primary-foreground: hsl(0 0% 98%);
|
||||
|
||||
--color-secondary: hsl(240 4.8% 95.9%);
|
||||
--color-secondary-foreground: hsl(240 5.9% 10%);
|
||||
|
||||
--color-muted: hsl(240 4.8% 95.9%);
|
||||
--color-muted-foreground: hsl(240 3.8% 46.1%);
|
||||
|
||||
--color-accent: hsl(240 4.8% 95.9%);
|
||||
--color-accent-foreground: hsl(240 5.9% 10%);
|
||||
|
||||
--color-destructive: hsl(0 84.2% 60.2%);
|
||||
--color-destructive-foreground: hsl(0 0% 98%);
|
||||
|
||||
/* Softened borders to reduce the "inky" grid feel */
|
||||
--color-border: hsl(240 5.9% 90%);
|
||||
--color-input: hsl(240 5.9% 90%);
|
||||
--shadow-premium: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
--color-ring: hsl(240 10% 3.9%);
|
||||
|
||||
/* Tighter shadcn radii for a sharper, professional edge */
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
/* Preserved Layout Tokens */
|
||||
--spacing-header: 4rem;
|
||||
--spacing-sidebar: 18rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Standard shadcn base radius */
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased selection:bg-primary/10 selection:text-primary;
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
}
|
||||
|
||||
/* Preserved Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-muted-foreground/20 rounded-full hover:bg-muted-foreground/40 transition-colors;
|
||||
}
|
||||
}
|
||||
|
||||
/* Preserved Print Optimization */
|
||||
@layer utilities {
|
||||
@media print {
|
||||
.no-print {
|
||||
@apply !hidden;
|
||||
}
|
||||
|
||||
.print-only {
|
||||
@apply !block;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply !bg-white !text-black !p-0 !m-0;
|
||||
}
|
||||
|
||||
.print-page-number {
|
||||
@apply fixed bottom-4 right-4 text-[10px] font-bold text-slate-400;
|
||||
counter-increment: page-counter;
|
||||
content: "Page " counter(page-counter);
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render } from 'solid-js/web';
|
||||
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');
|
||||
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
throw new Error(
|
||||
'Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?',
|
||||
);
|
||||
}
|
||||
|
||||
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={(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!);
|
||||
@@ -0,0 +1,4 @@
|
||||
export const NOTICE_TO_OWNERS = `
|
||||
NOTICE TO OWNER
|
||||
Failure of this contractor to pay those persons supplying material or services to complete this contract can result in the filing of a mechanic's lien on the property which is the subject of this contract pursuant to Chapter 429 RSMo. To avoid this result you may ask this contractor for "lien waivers" from all persons supplying material or services for the work described in this contract. Failure to secure lien waivers may result in your paying for labor and material twice. Payment due upon receipt. Any payment not received within 30 days will be assessed a 1 1/2% per month finance charge. As a part of the consideration hereof, the customer agrees to pay all costs of collection, including but not limited to all court costs and reasonable attorney's fees, if this account is placed for collection.
|
||||
`;
|
||||
@@ -0,0 +1,192 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { createStore, produce } from 'solid-js/store';
|
||||
import type { ExtractedItem } from '../utils/csv-parser';
|
||||
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[]>([]);
|
||||
const [subScopes, setSubScopes] = createStore<SubScope[]>([]);
|
||||
const [estimateItems, setEstimateItems] = createStore<EstimateItem[]>([]);
|
||||
|
||||
const [clientInfo, setClientInfo] = createStore({
|
||||
name: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
fax: ''
|
||||
});
|
||||
|
||||
const [jobInfo, setJobInfo] = createStore<JobInfo>({
|
||||
number: '',
|
||||
name: '',
|
||||
address: '',
|
||||
workOrder: ''
|
||||
});
|
||||
|
||||
const [generalEstimateNotes, setGeneralEstimateNotes] = createSignal('');
|
||||
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,
|
||||
setItems: setActiveItems,
|
||||
ignoredLines: activeIgnoredLines,
|
||||
setIgnoredLines: setActiveIgnoredLines,
|
||||
|
||||
// Estimate Meta
|
||||
estimateId: activeEstimateId,
|
||||
setEstimateId: setActiveEstimateId,
|
||||
estimateName: activeEstimateName,
|
||||
setEstimateName: setActiveEstimateName,
|
||||
history: activeHistory,
|
||||
setHistory: setActiveHistory,
|
||||
latestSnapshot: activeLatestSnapshot,
|
||||
setLatestSnapshot: setActiveLatestSnapshot,
|
||||
isLatestVersion: isLatestVersion,
|
||||
setIsLatestVersion: setIsLatestVersion,
|
||||
|
||||
// Estimate Builder
|
||||
scopes, setScopes,
|
||||
subScopes, setSubScopes,
|
||||
estimateItems, setEstimateItems,
|
||||
clientInfo, setClientInfo,
|
||||
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,
|
||||
setTemplateId: setActiveTemplateId,
|
||||
templateName: activeTemplateName,
|
||||
setTemplateName: setActiveTemplateName,
|
||||
templateItems: activeTemplateItems,
|
||||
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();
|
||||
}
|
||||
};
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
export interface Scope {
|
||||
id: string;
|
||||
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 {
|
||||
number: string;
|
||||
name: string;
|
||||
address: string;
|
||||
workOrder: string;
|
||||
}
|
||||
|
||||
export type PricingType = 'percent' | 'amount';
|
||||
|
||||
export interface EstimateItem {
|
||||
id: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
markup: number;
|
||||
markupType: PricingType;
|
||||
contingency: number;
|
||||
contingencyType: PricingType;
|
||||
scopeId?: string;
|
||||
subScopeId?: string;
|
||||
}
|
||||
|
||||
export interface TemplateItem {
|
||||
id: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
markup: number;
|
||||
markupType: PricingType;
|
||||
contingency: number;
|
||||
contingencyType: PricingType;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
export interface ExtractedItem {
|
||||
id: string;
|
||||
description: string;
|
||||
quantity: string;
|
||||
}
|
||||
|
||||
export function parseCSV(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let currentRow: string[] = [];
|
||||
let currentCell = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const nextChar = text[i + 1];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && nextChar === '"') {
|
||||
currentCell += '"';
|
||||
i++;
|
||||
} else if (!inQuotes) {
|
||||
inQuotes = true;
|
||||
} else {
|
||||
const isClosing = nextChar === ',' || nextChar === '\r' || nextChar === '\n' || nextChar === undefined;
|
||||
if (isClosing) {
|
||||
inQuotes = false;
|
||||
} else {
|
||||
currentCell += '"';
|
||||
}
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
currentRow.push(currentCell);
|
||||
currentCell = '';
|
||||
} else if ((char === '\r' || char === '\n') && !inQuotes) {
|
||||
if (currentCell || currentRow.length > 0) currentRow.push(currentCell);
|
||||
if (currentRow.length > 0) rows.push(currentRow);
|
||||
currentRow = [];
|
||||
currentCell = '';
|
||||
if (char === '\r' && nextChar === '\n') i++;
|
||||
} else {
|
||||
currentCell += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentCell || currentRow.length > 0) {
|
||||
currentRow.push(currentCell);
|
||||
rows.push(currentRow);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export interface ExtractionResult {
|
||||
items: ExtractedItem[];
|
||||
ignored: string[][];
|
||||
}
|
||||
|
||||
export function extractItemsFromCSV(csvText: string): ExtractionResult {
|
||||
const rows = parseCSV(csvText);
|
||||
const extractedItems: ExtractedItem[] = [];
|
||||
const ignored: string[][] = [];
|
||||
|
||||
const regexInline = /^Item\s*:\s*([\s\S]*?)\s*\(\s*([\d,.]+)\s*\).*$/i;
|
||||
|
||||
rows.forEach(row => {
|
||||
let matched = false;
|
||||
row.forEach((cell, index) => {
|
||||
if (cell && typeof cell === 'string' && cell.trim().toLowerCase().startsWith('item')) {
|
||||
const cellContent = cell.trim();
|
||||
const match = cellContent.match(regexInline);
|
||||
|
||||
if (match) {
|
||||
let desc = match[1].trim().replace(/,\s*$/, '').trim();
|
||||
|
||||
extractedItems.push({
|
||||
id: crypto.randomUUID(),
|
||||
description: desc,
|
||||
quantity: match[2].trim()
|
||||
});
|
||||
matched = true;
|
||||
} else {
|
||||
let description = cellContent.replace(/^Item\s*:\s*/i, '').trim().replace(/,\s*$/, '').trim();
|
||||
|
||||
let quantity = '';
|
||||
if (index + 1 < row.length) {
|
||||
const nextCell = row[index + 1];
|
||||
if (nextCell) {
|
||||
const nextCellStr = nextCell.toString().trim();
|
||||
const qtyMatch = nextCellStr.match(/[\d,.]+/);
|
||||
if (qtyMatch) {
|
||||
quantity = qtyMatch[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extractedItems.push({
|
||||
id: crypto.randomUUID(),
|
||||
description,
|
||||
quantity: quantity || ""
|
||||
});
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!matched) {
|
||||
// Check if row is not just empty strings
|
||||
if (row.some(cell => cell.trim() !== '')) {
|
||||
ignored.push(row);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { items: extractedItems, ignored };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
// Initialize the PocketBase client configured for the ccllc.pro backend
|
||||
export const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||
|
||||
export const COLLECTIONS = {
|
||||
TEMPLATES: 'EstiMaker_Templates',
|
||||
SCOPE_TEXT_TEMPLATES: 'EstiMaker_ScopeTextTemplates',
|
||||
ESTIMATES: 'EstiMaker_Estimates',
|
||||
STANDARD_PRICES: 'EstiMaker_StandardPrices'
|
||||
} as const;
|
||||
File diff suppressed because one or more lines are too long
@@ -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 @@
|
||||
export interface ExtractedItem {
|
||||
id: string;
|
||||
description: string;
|
||||
quantity: string;
|
||||
}
|
||||
|
||||
export interface ExtractionResult {
|
||||
items: ExtractedItem[];
|
||||
ignored: string[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses raw XML text into a 2D array of strings based on <LINE> and <CELL> tags.
|
||||
*/
|
||||
export function parseXML(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
const lines = text.split('</LINE>');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.includes('<LINE>')) continue;
|
||||
|
||||
// Extract content inside <LINE> ... </LINE>
|
||||
const lineContent = line.substring(line.indexOf('<LINE>') + 6);
|
||||
const cells = lineContent.split('</CELL>');
|
||||
const currentRow: string[] = [];
|
||||
|
||||
for (const cell of cells) {
|
||||
if (!cell.includes('<CELL')) continue;
|
||||
|
||||
// Extract content inside <CELL ...> ... </CELL>
|
||||
const cellContent = cell.substring(cell.indexOf('>') + 1);
|
||||
currentRow.push(cellContent.trim());
|
||||
}
|
||||
|
||||
if (currentRow.length > 0) {
|
||||
rows.push(currentRow);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts items from the parsed XML grid, matching the CSV parser's output structure.
|
||||
*/
|
||||
export function extractItemsFromXML(xmlText: string): ExtractionResult {
|
||||
const rows = parseXML(xmlText);
|
||||
const extractedItems: ExtractedItem[] = [];
|
||||
const ignored: string[][] = [];
|
||||
|
||||
rows.forEach(row => {
|
||||
let matched = false;
|
||||
|
||||
// Valid data lines in the provided XML have at least 8 columns.
|
||||
// Index 6 maps to "Item", Index 7 maps to "Order" (Quantity)
|
||||
if (row.length >= 8) {
|
||||
const itemDesc = row[6];
|
||||
const quantityStr = row[7];
|
||||
|
||||
// Filter out the header row ('Item') and empty/layout spacer rows ('.')
|
||||
if (itemDesc && quantityStr && itemDesc !== 'Item' && itemDesc !== '.') {
|
||||
const qtyMatch = quantityStr.match(/[\d,.]+/);
|
||||
|
||||
if (qtyMatch) {
|
||||
// Remove trailing commas, identically to the CSV parser logic
|
||||
const desc = itemDesc.replace(/,\s*$/, '').trim();
|
||||
|
||||
extractedItems.push({
|
||||
id: crypto.randomUUID(),
|
||||
description: desc,
|
||||
quantity: qtyMatch[0]
|
||||
});
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
// Check if row is not just empty strings, mimicking original logic
|
||||
if (row.some(cell => cell !== '')) {
|
||||
ignored.push(row);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { items: extractedItems, ignored };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,226 @@
|
||||
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 { appStore } from '../store/appStore';
|
||||
import { getItemsPricingTotals } from '../utils/pricing';
|
||||
|
||||
const TemplateCreator: Component = () => {
|
||||
const templateId = appStore.templateId;
|
||||
const setTemplateId = appStore.setTemplateId;
|
||||
const templateName = appStore.templateName;
|
||||
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);
|
||||
|
||||
const handleAddItem = () => {
|
||||
const newItem: TemplateItem = {
|
||||
id: crypto.randomUUID(),
|
||||
description: '',
|
||||
quantity: 1,
|
||||
unitPrice: 0,
|
||||
markup: 20, // default
|
||||
markupType: 'percent',
|
||||
contingency: 10, // default
|
||||
contingencyType: 'percent'
|
||||
};
|
||||
setItems([...items, newItem]);
|
||||
};
|
||||
|
||||
const handleUpdateItem = (id: string, updates: Partial<TemplateItem>) => {
|
||||
setItems(item => item.id === id, updates);
|
||||
};
|
||||
|
||||
const handleDeleteItem = (id: string) => {
|
||||
setItems(items.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
const handleLoadTemplate = (record: any) => {
|
||||
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 && modifiers.length === 0)) return;
|
||||
|
||||
try {
|
||||
const record = {
|
||||
name: templateName(),
|
||||
items: JSON.parse(JSON.stringify(items)), // Ensure plain objects for PocketBase
|
||||
modifiers: JSON.parse(JSON.stringify(modifiers)),
|
||||
defaultSubScopeName: defaultSubScopeName()
|
||||
};
|
||||
|
||||
if (templateId()) {
|
||||
await pb.collection(COLLECTIONS.TEMPLATES).update(templateId()!, record);
|
||||
alert(`Template "${record.name}" updated successfully!`);
|
||||
} else {
|
||||
const newRecord = await pb.collection(COLLECTIONS.TEMPLATES).create(record);
|
||||
setTemplateId(newRecord.id);
|
||||
alert(`Template "${record.name}" saved successfully!`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving template:', error);
|
||||
alert('Failed to save template. Make sure the collection and fields exist.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center py-6 px-4 font-sans print:bg-transparent print:border-none print:p-0">
|
||||
<div class="max-w-4xl w-full bg-white rounded-xl shadow-premium p-8 mb-8 border border-gray-100 no-print">
|
||||
<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">Template Creator</h1>
|
||||
<p class="text-gray-500 font-medium">Create templates for your estimate sub-scopes.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleNewTemplate}
|
||||
class="flex items-center gap-2 px-4 py-2.5 text-sm font-bold text-gray-700 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 transition-all shadow-sm"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowLoadModal(true)}
|
||||
class="flex items-center gap-2 px-4 py-2.5 text-sm font-bold text-blue-600 bg-blue-50 border border-blue-100 rounded-xl hover:bg-blue-100 transition-all shadow-sm"
|
||||
>
|
||||
<FolderOpen class="w-4 h-4" />
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveTemplate}
|
||||
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" />
|
||||
{templateId() ? 'Update Template' : 'Save Template'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
items={items}
|
||||
onAddItem={handleAddItem}
|
||||
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
|
||||
show={showLoadModal()}
|
||||
onClose={() => setShowLoadModal(false)}
|
||||
onLoad={handleLoadTemplate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateCreator;
|
||||
Reference in New Issue
Block a user