Compare commits
14 Commits
2003729279
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a56d5185b | |||
| 4a623e76b4 | |||
| 130130a7ed | |||
| da80fcffe9 | |||
| 1f25293750 | |||
| 1b875eb0b6 | |||
| 8a5092c56d | |||
| 4bebb04c5f | |||
| 0b4e60970e | |||
| 821cf379a9 | |||
| 2dec1f1f93 | |||
| 3822452d8b | |||
| 14b8dc1215 | |||
| c5f20d2b64 |
@ -7,7 +7,7 @@ import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
files: ['src/**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
@ -26,4 +26,21 @@ export default defineConfig([
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['server.js', 'migrate.js'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.node,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
}
|
||||
])
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LB Quote Generator</title>
|
||||
</head>
|
||||
|
||||
31
migrate.js
Normal file
31
migrate.js
Normal file
@ -0,0 +1,31 @@
|
||||
import fs from 'fs';
|
||||
import pg from 'pg';
|
||||
|
||||
const { Pool } = pg;
|
||||
const pool = new Pool({
|
||||
connectionString: "postgresql://app_user:Al5eVT1SJ7i4UHmSZr1F@204.168.129.249:5432/app_data",
|
||||
ssl: false
|
||||
});
|
||||
|
||||
const quotes = JSON.parse(fs.readFileSync('quotes.json', 'utf8'));
|
||||
|
||||
async function migrate() {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS quotes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
quote_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
data JSONB NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
for (const q of quotes) {
|
||||
await pool.query(
|
||||
'INSERT INTO quotes (quote_id, data) VALUES ($1, $2) ON CONFLICT (quote_id) DO UPDATE SET data = $2',
|
||||
[String(q.id), q]
|
||||
);
|
||||
}
|
||||
console.log(`Successfully migrated ${quotes.length} quotes to your new production database!`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
migrate().catch(console.error);
|
||||
@ -1,3 +1,5 @@
|
||||
[variables]
|
||||
NIXPACKS_NODE_VERSION = '22'
|
||||
|
||||
[phases.setup]
|
||||
nixPkgsArchive = 'fe1bc4be004dc12721ea2cb671b08a21de01c6976960ef8a1248798589679e16'
|
||||
nixPkgsArchive = 'e4f44407a7a9a5561cd6b589a6d27e7a8f91a720'
|
||||
|
||||
147
package-lock.json
generated
147
package-lock.json
generated
@ -12,6 +12,7 @@
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"pg": "^8.20.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"xlsx": "^0.18.5"
|
||||
@ -3425,6 +3426,95 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
"pg-protocol": "^1.13.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
|
||||
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
|
||||
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
|
||||
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@ -3474,6 +3564,45 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@ -3915,6 +4044,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
@ -4307,6 +4445,15 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"pg": "^8.20.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"xlsx": "^0.18.5"
|
||||
|
||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 571 B |
Binary file not shown.
BIN
public/itemsold.xlsx
Normal file
BIN
public/itemsold.xlsx
Normal file
Binary file not shown.
403
quotes.json
403
quotes.json
@ -1,13 +1,4 @@
|
||||
[
|
||||
{
|
||||
"date": "2026-03-01",
|
||||
"customer": {
|
||||
"name": "Test",
|
||||
"address": "123 Main"
|
||||
},
|
||||
"id": "1234",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"id": 4460,
|
||||
"date": "March 1, 2026",
|
||||
@ -28,38 +19,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10318,
|
||||
"date": "March 2, 2026",
|
||||
"customer": {
|
||||
"name": "Test User",
|
||||
"address": "Test Street",
|
||||
"city": "Chatham",
|
||||
"province": "ON",
|
||||
"postalCode": "N7M 5J5",
|
||||
"phone": "5193540540"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"Item ID": "MDL-5B3PR7",
|
||||
"Description": "Earth Drill,6HP B&S PW RC 13:1Vanguard Engine#12V3320012F",
|
||||
"Price": 5801.37,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"Item ID": "8X36-SSS",
|
||||
"Description": "Auger, Snap-on",
|
||||
"Price": 483.23,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"Item ID": "9X42-SSP",
|
||||
"Description": "Auger, Snap-on, Pengo Style",
|
||||
"Price": 786.13,
|
||||
"quantity": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9135,
|
||||
"date": "March 2, 2026",
|
||||
@ -369,5 +328,367 @@
|
||||
}
|
||||
],
|
||||
"shippingCost": 0
|
||||
},
|
||||
{
|
||||
"id": 5358,
|
||||
"date": "March 16, 2026",
|
||||
"customer": {
|
||||
"name": "Etobicoke Tool & Equipment Rental",
|
||||
"address": "883 Kipling Ave",
|
||||
"city": "Etobicoke",
|
||||
"province": "ON",
|
||||
"postalCode": "M8Z 5H2",
|
||||
"phone": "416 233 6262",
|
||||
"email": "erent@sympatico.ca"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"Item ID": "10470",
|
||||
"Description": "Toggle Kill Switch Asy., Handle(incl. Nut & On-Off",
|
||||
"Price": 16.511121,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"Item ID": "4031-1",
|
||||
"Description": "Throttle Lever Kit",
|
||||
"Price": 55.796201999999994,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"Item ID": "3089-8H",
|
||||
"Description": "Throttle Cable Assy. 120\"OAL 8 H/8 VANG Bracketless Model",
|
||||
"Price": 53.329023,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"Item ID": "3007-6H",
|
||||
"Description": "Wire, Kill Switch, 115\" w/Flag(Rt Angle Conn)",
|
||||
"Price": 23.533092000000003,
|
||||
"quantity": 1
|
||||
}
|
||||
],
|
||||
"shippingCost": 0
|
||||
},
|
||||
{
|
||||
"id": 6353,
|
||||
"date": "March 17, 2026",
|
||||
"customer": {
|
||||
"name": "Burelle Rentools Inc.",
|
||||
"address": "1031 Limoges Rd",
|
||||
"city": "Limoges",
|
||||
"province": "ON",
|
||||
"postalCode": "K0A 1W0",
|
||||
"phone": "613 443 5292",
|
||||
"email": "burelle19@icloud.com"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"Item ID": "HYD-NTV11H",
|
||||
"Description": "Hydraulic, Non-Towable Drill, 11 HP Honda GXV340",
|
||||
"Price": 6624.375614999999,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"Item ID": "10X36-SSP",
|
||||
"Description": "Auger, Snap-on, Pengo Style",
|
||||
"Price": 726.86889,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"Item ID": "10X36-SSS",
|
||||
"Description": "Auger, Snap-on",
|
||||
"Price": 530.443485,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"Item ID": "38130",
|
||||
"Description": "TRANSPORT FRAME ASSY RED",
|
||||
"Price": 1473.664995,
|
||||
"quantity": 1
|
||||
}
|
||||
],
|
||||
"shippingCost": 0
|
||||
},
|
||||
{
|
||||
"id": 9684,
|
||||
"date": "March 19, 2026",
|
||||
"customer": {
|
||||
"name": "TerraBurst",
|
||||
"address": " 918 16 Ave NW #8",
|
||||
"city": "Calgary",
|
||||
"province": "AB",
|
||||
"postalCode": "T2M 0K3",
|
||||
"phone": "403 862 8522",
|
||||
"email": "nathan@terraburst.ca"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"Item ID": "MDL-8H",
|
||||
"Description": "Earth Drill, 8 HP Honda Engine#GX240 w/Flat Free Tires",
|
||||
"Price": 5852.907719999999,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"Item ID": "20200",
|
||||
"Description": "Sidewalk Auger Kit",
|
||||
"Price": 980.229195,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"Item ID": "20100-2",
|
||||
"Description": "Horizontal Drill Kit w/2\" Bit",
|
||||
"Price": 933.73236,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"Item ID": "20100-3",
|
||||
"Description": "Horizontal Drill Kit w/3\" Bit",
|
||||
"Price": 933.73236,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"Item ID": "20100-4",
|
||||
"Description": "Horizontal Drill Kit w/4\" Bit",
|
||||
"Price": 933.73236,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"Item ID": "20100-6",
|
||||
"Description": "Horizontal Drill Kit w/6\" Bit",
|
||||
"Price": 972.637875,
|
||||
"quantity": 1
|
||||
}
|
||||
],
|
||||
"shippingCost": 0
|
||||
},
|
||||
{
|
||||
"id": 2908,
|
||||
"date": "March 20, 2026",
|
||||
"customer": {
|
||||
"name": "test",
|
||||
"address": "",
|
||||
"city": "",
|
||||
"province": "on",
|
||||
"postalCode": "",
|
||||
"phone": "",
|
||||
"email": ""
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"Item ID": "MDL-5H2",
|
||||
"Description": "Earth Drill, 5.5HP Honda, 20:1Transmission",
|
||||
"Price": 4674.3552899999995,
|
||||
"quantity": 1
|
||||
}
|
||||
],
|
||||
"shippingCost": 0
|
||||
},
|
||||
{
|
||||
"id": 8071,
|
||||
"date": "March 24, 2026",
|
||||
"customer": {
|
||||
"name": "LOCATION WINDSOR",
|
||||
"address": "89 RUE PRINCIPALE S",
|
||||
"city": "WINDSOR",
|
||||
"province": "QC",
|
||||
"postalCode": "J1S 2B9",
|
||||
"phone": "819 845 7874",
|
||||
"email": "martin.dion@locationwindsor.com"
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"Item ID": "4X36-SSS",
|
||||
"Description": "Auger, Snap-on",
|
||||
"Price": 383.36166000000003,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "6X36-SSP",
|
||||
"Description": "Auger, Snap-on, Pengo Style",
|
||||
"Price": 577.8892350000001,
|
||||
"quantity": 2,
|
||||
"discount": 0
|
||||
}
|
||||
],
|
||||
"shippingCost": 0
|
||||
},
|
||||
{
|
||||
"id": 7294,
|
||||
"date": "May 14, 2026",
|
||||
"customer": {
|
||||
"name": "",
|
||||
"contactName": "",
|
||||
"address": "",
|
||||
"city": "",
|
||||
"province": "",
|
||||
"postalCode": "",
|
||||
"phone": "",
|
||||
"email": ""
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"Item ID": "MDL-5H",
|
||||
"Description": "Earth Drill, 5.5HP Honda Engine #GX160",
|
||||
"Price": 4623.113879999999,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "MDL-8H2R7",
|
||||
"Description": "Earth Drill, 8 HP Honda, 20:1 Trans & Roll Cage",
|
||||
"Price": 6183.13014,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "3002-KEP",
|
||||
"Description": "Nut, KEP NC, 5/16 pltd. (Nut w/Lock Washer)",
|
||||
"Price": 0.7970885999999999,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "3000-B",
|
||||
"Description": "Motor Carrier",
|
||||
"Price": 189.3085425,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "3002",
|
||||
"Description": "Bolt, 5/16 x 1-1/2 Hex NC Gr 5Zinc Plated",
|
||||
"Price": 1.2335895,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "MDL-5B3PR7",
|
||||
"Description": "Earth Drill,6HP B&S PW RC 13:1Vanguard Engine#12V3320012F",
|
||||
"Price": 5091.403432499999,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "4034-3",
|
||||
"Description": "Nut, Hex, 10-32 Pltd",
|
||||
"Price": 0.49343580000000004,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "4018-2H",
|
||||
"Description": "Clamp, Flex Shaft",
|
||||
"Price": 23.153526,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "4018-2H",
|
||||
"Description": "Clamp, Flex Shaft",
|
||||
"Price": 23.153526,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "3000-B",
|
||||
"Description": "Motor Carrier",
|
||||
"Price": 189.3085425,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "3063-A",
|
||||
"Description": "Guide Spool Assy., 8HP Honda Direct Throttle",
|
||||
"Price": 22.963743,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "3004",
|
||||
"Description": "Wheel (Includes #3006-1 Cap)",
|
||||
"Price": 42.321608999999995,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "3000-B",
|
||||
"Description": "Motor Carrier",
|
||||
"Price": 189.3085425,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "3000-B",
|
||||
"Description": "Motor Carrier",
|
||||
"Price": 189.3085425,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "CAP",
|
||||
"Description": "Cap, LITTLE BEAVER",
|
||||
"Price": 39.095298,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "MDL-5B",
|
||||
"Description": "Earth Drill, 6 HP B&S VanguardEngine #12V3320012F",
|
||||
"Price": 4526.324549999999,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
}
|
||||
],
|
||||
"shippingCost": 0
|
||||
},
|
||||
{
|
||||
"id": 5690,
|
||||
"date": "June 3, 2026",
|
||||
"customer": {
|
||||
"name": "PERFECT POST",
|
||||
"contactName": "",
|
||||
"address": "",
|
||||
"city": "",
|
||||
"province": "",
|
||||
"postalCode": "",
|
||||
"phone": "",
|
||||
"email": ""
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"Item ID": "MDL-5HPR7",
|
||||
"Description": "Earth Drill, 5.5HP Honda EnginRoll Cage and Flat Free Tires",
|
||||
"Weight": 161,
|
||||
"Price": 5342.1757875,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "10X42-SSC",
|
||||
"Description": "Auger, Snap-on, Carbide Point",
|
||||
"Weight": 28,
|
||||
"Price": 855.1449449999998,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "4064",
|
||||
"Description": "Rivet, 3/16\" Aluminum Pop Rivet1/4\" Grip",
|
||||
"Weight": 0.01,
|
||||
"Price": 0.9523655999999998,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
},
|
||||
{
|
||||
"Item ID": "16X42-SSPF",
|
||||
"Description": "Auger, Snap-On, Full Flighted,Pengo Style",
|
||||
"Weight": 60,
|
||||
"Price": 1410.691545,
|
||||
"quantity": 1,
|
||||
"discount": 0
|
||||
}
|
||||
],
|
||||
"shippingCost": 0
|
||||
}
|
||||
]
|
||||
78
server.js
78
server.js
@ -3,13 +3,35 @@ import cors from 'cors';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import pg from 'pg';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
const DATA_FILE = path.join(__dirname, 'quotes.json');
|
||||
|
||||
// Use DATA_DIR from environment if available (for Dokploy volumes), otherwise fallback to the current directory
|
||||
const DATA_DIR = process.env.DATA_DIR || __dirname;
|
||||
const DATA_FILE = path.join(DATA_DIR, 'quotes.json');
|
||||
|
||||
// Database Setup
|
||||
const { Pool } = pg;
|
||||
const pool = process.env.DATABASE_URL ? new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.PG_SSL === 'true' ? { rejectUnauthorized: false } : false
|
||||
}) : null;
|
||||
|
||||
if (pool) {
|
||||
pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS quotes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
quote_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
data JSONB NOT NULL
|
||||
)
|
||||
`).then(() => console.log("PostgreSQL Connected & Table Verified"))
|
||||
.catch(err => console.error("Could not create PostgreSQL table:", err));
|
||||
}
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
@ -20,7 +42,7 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Helper to read data
|
||||
// Helper to read data (legacy JSON mode)
|
||||
const readData = () => {
|
||||
try {
|
||||
if (!fs.existsSync(DATA_FILE)) {
|
||||
@ -34,7 +56,7 @@ const readData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to write data
|
||||
// Helper to write data (legacy JSON mode)
|
||||
const writeData = (data) => {
|
||||
try {
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');
|
||||
@ -44,34 +66,65 @@ const writeData = (data) => {
|
||||
};
|
||||
|
||||
// GET all quotes
|
||||
app.get('/api/quotes', (req, res) => {
|
||||
app.get('/api/quotes', async (req, res) => {
|
||||
if (pool) {
|
||||
try {
|
||||
const result = await pool.query('SELECT data FROM quotes ORDER BY id ASC');
|
||||
res.json(result.rows.map(row => row.data));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "Failed to fetch from database" });
|
||||
}
|
||||
} else {
|
||||
const quotes = readData();
|
||||
res.json(quotes);
|
||||
}
|
||||
});
|
||||
|
||||
// POST a new or updated quote
|
||||
app.post('/api/quotes', (req, res) => {
|
||||
app.post('/api/quotes', async (req, res) => {
|
||||
const newQuote = req.body;
|
||||
if (pool) {
|
||||
try {
|
||||
await pool.query(
|
||||
'INSERT INTO quotes (quote_id, data) VALUES ($1, $2) ON CONFLICT (quote_id) DO UPDATE SET data = $2',
|
||||
[String(newQuote.id), newQuote]
|
||||
);
|
||||
res.status(201).json(newQuote);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "Failed to save to database" });
|
||||
}
|
||||
} else {
|
||||
let quotes = readData();
|
||||
|
||||
const existingIndex = quotes.findIndex(q => q.id === newQuote.id);
|
||||
if (existingIndex >= 0) {
|
||||
quotes[existingIndex] = newQuote; // Update
|
||||
} else {
|
||||
quotes.push(newQuote); // Add new
|
||||
}
|
||||
|
||||
writeData(quotes);
|
||||
res.status(201).json(newQuote);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE a quote
|
||||
app.delete('/api/quotes/:id', (req, res) => {
|
||||
const idToDelete = parseInt(req.params.id) || req.params.id; // Handle number or string
|
||||
app.delete('/api/quotes/:id', async (req, res) => {
|
||||
const idToDelete = req.params.id;
|
||||
if (pool) {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM quotes WHERE quote_id = $1', [String(idToDelete)]);
|
||||
if (result.rowCount > 0) res.status(200).json({ message: "Quote deleted successfully" });
|
||||
else res.status(404).json({ message: "Quote not found" });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "Failed to delete from database" });
|
||||
}
|
||||
} else {
|
||||
const idToDeleteFallback = parseInt(idToDelete) || idToDelete; // Handle number or string
|
||||
let quotes = readData();
|
||||
|
||||
const initialLength = quotes.length;
|
||||
quotes = quotes.filter(q => q.id != idToDelete);
|
||||
quotes = quotes.filter(q => q.id != idToDeleteFallback);
|
||||
|
||||
if (quotes.length < initialLength) {
|
||||
writeData(quotes);
|
||||
@ -79,6 +132,7 @@ app.delete('/api/quotes/:id', (req, res) => {
|
||||
} else {
|
||||
res.status(404).json({ message: "Quote not found" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Serve frontend static files
|
||||
@ -91,4 +145,6 @@ app.get(/(.*)/, (req, res) => {
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Backend server running on port ${PORT}`);
|
||||
if (pool) console.log(`[INFO] DATABASE_URL detected. Running in PostgreSQL mode.`);
|
||||
else console.log(`[INFO] No DATABASE_URL detected. Running in legacy JSON file mode.`);
|
||||
});
|
||||
|
||||
97
src/App.jsx
97
src/App.jsx
@ -10,13 +10,15 @@ import './index.css';
|
||||
function App() {
|
||||
const [catalogItems, setCatalogItems] = useState([]);
|
||||
const [quoteItems, setQuoteItems] = useState([]);
|
||||
const [quoteId, setQuoteId] = useState('');
|
||||
const [quoteDate, setQuoteDate] = useState('');
|
||||
const [quoteId, setQuoteId] = useState(() => Math.floor(Math.random() * 10000) + 1000);
|
||||
const [quoteDate, setQuoteDate] = useState(() => new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }));
|
||||
const [savedQuotes, setSavedQuotes] = useState([]);
|
||||
const [shippingCost, setShippingCost] = useState(0);
|
||||
const [isWorkOrderMode, setIsWorkOrderMode] = useState(false);
|
||||
|
||||
const [customer, setCustomer] = useState({
|
||||
name: '',
|
||||
contactName: '',
|
||||
address: '',
|
||||
city: '',
|
||||
province: '',
|
||||
@ -25,25 +27,13 @@ function App() {
|
||||
email: ''
|
||||
});
|
||||
|
||||
// Initialize quote ID and date
|
||||
useEffect(() => {
|
||||
generateNewQuoteId();
|
||||
loadSavedQuotes();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCatalog = async () => {
|
||||
const data = await loadItemsFromExcel();
|
||||
setCatalogItems(data);
|
||||
};
|
||||
fetchCatalog();
|
||||
}, []);
|
||||
|
||||
const generateNewQuoteId = () => {
|
||||
setQuoteId(Math.floor(Math.random() * 10000) + 1000);
|
||||
setQuoteDate(new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }));
|
||||
};
|
||||
|
||||
// Initialize quote ID and date
|
||||
useEffect(() => {
|
||||
const loadSavedQuotes = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/quotes');
|
||||
@ -56,6 +46,23 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
loadSavedQuotes();
|
||||
|
||||
const handleAfterPrint = () => {
|
||||
setIsWorkOrderMode(false);
|
||||
};
|
||||
window.addEventListener('afterprint', handleAfterPrint);
|
||||
return () => window.removeEventListener('afterprint', handleAfterPrint);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCatalog = async () => {
|
||||
const data = await loadItemsFromExcel();
|
||||
setCatalogItems(data);
|
||||
};
|
||||
fetchCatalog();
|
||||
}, []);
|
||||
|
||||
const handleAddItem = (item) => {
|
||||
setQuoteItems([...quoteItems, item]);
|
||||
};
|
||||
@ -66,8 +73,24 @@ function App() {
|
||||
setQuoteItems(newItems);
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
const handleEditItem = (index, field, value) => {
|
||||
const newItems = [...quoteItems];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
setQuoteItems(newItems);
|
||||
};
|
||||
|
||||
const handlePrintQuote = () => {
|
||||
setIsWorkOrderMode(false);
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handlePrintWorkOrder = () => {
|
||||
setIsWorkOrderMode(true);
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleSaveQuote = async () => {
|
||||
@ -144,44 +167,25 @@ function App() {
|
||||
|
||||
const handleNewQuote = () => {
|
||||
generateNewQuoteId();
|
||||
setCustomer({ name: '', address: '', city: '', province: '', postalCode: '', phone: '', email: '' });
|
||||
setCustomer({ name: '', contactName: '', address: '', city: '', province: '', postalCode: '', phone: '', email: '' });
|
||||
setQuoteItems([]);
|
||||
setShippingCost(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
{/* Header - Visible on screen and print */}
|
||||
<div className="header">
|
||||
{/* Header - Visible on screen */}
|
||||
<div className="header no-print">
|
||||
<img src="/Logo.jpg" alt="Company Logo" style={{ height: '80px', marginBottom: '1rem' }} />
|
||||
<h1>Little Beaver Earth Augers</h1>
|
||||
<p>Official Quote Generator</p>
|
||||
<p>{isWorkOrderMode ? 'Work Order' : 'Official Quote Generator'}</p>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Print-only details */}
|
||||
<div style={{ display: 'none' }} className="print-only-block">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2rem' }}>
|
||||
<div>
|
||||
<h3>Quote For:</h3>
|
||||
<p>{customer.name || 'N/A'}</p>
|
||||
<p>{customer.address || 'N/A'}</p>
|
||||
<p>{customer.city ? `${customer.city}, ` : ''}{customer.province} {customer.postalCode}</p>
|
||||
<p>{customer.phone || 'N/A'}</p>
|
||||
<p>{customer.email || 'N/A'}</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<h3>Quote Details:</h3>
|
||||
<p><strong>Date:</strong> {quoteDate}</p>
|
||||
<p><strong>Quote #:</strong> {quoteId}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="no-print" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<h2 style={{ margin: 0 }}>Current Quote: #{quoteId}</h2>
|
||||
<h2 style={{ margin: 0 }}>{isWorkOrderMode ? 'Current Work Order' : 'Current Quote'}: #{quoteId}</h2>
|
||||
<button className="btn" style={{ backgroundColor: 'white', border: '1px solid var(--border-color)', color: 'var(--text-main)' }} onClick={handleNewQuote}>
|
||||
<FilePlus size={18} /> New Quote
|
||||
</button>
|
||||
@ -189,7 +193,7 @@ function App() {
|
||||
|
||||
{/* Customer Form */}
|
||||
<div className="no-print">
|
||||
<CustomerForm customer={customer} onChange={setCustomer} />
|
||||
<CustomerForm customer={customer} onChange={setCustomer} savedQuotes={savedQuotes} />
|
||||
</div>
|
||||
|
||||
{/* Item Selector */}
|
||||
@ -200,9 +204,13 @@ function App() {
|
||||
<QuoteSummary
|
||||
items={quoteItems}
|
||||
customer={customer}
|
||||
quoteId={quoteId}
|
||||
quoteDate={quoteDate}
|
||||
shippingCost={shippingCost}
|
||||
onShippingChange={setShippingCost}
|
||||
onRemoveItem={handleRemoveItem}
|
||||
onEditItem={handleEditItem}
|
||||
isWorkOrderMode={isWorkOrderMode}
|
||||
/>
|
||||
|
||||
{/* Action Buttons */}
|
||||
@ -211,9 +219,12 @@ function App() {
|
||||
<button className="btn btn-primary" onClick={handleSaveQuote} style={{ padding: '1rem 2rem', fontSize: '1.1rem', backgroundColor: '#10b981' }}>
|
||||
<Save style={{ marginRight: '0.5rem' }} /> Save Quote
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handlePrint} style={{ padding: '1rem 2rem', fontSize: '1.1rem' }}>
|
||||
<button className="btn btn-primary" onClick={handlePrintQuote} style={{ padding: '1rem 2rem', fontSize: '1.1rem' }}>
|
||||
<Printer style={{ marginRight: '0.5rem' }} /> Print Quote
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handlePrintWorkOrder} style={{ padding: '1rem 2rem', fontSize: '1.1rem', backgroundColor: '#3b82f6' }}>
|
||||
<Printer style={{ marginRight: '0.5rem' }} /> Print Work Order
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,23 +1,76 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function CustomerForm({ customer, onChange }) {
|
||||
export default function CustomerForm({ customer, onChange, savedQuotes = [] }) {
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
onChange({ ...customer, [name]: value });
|
||||
};
|
||||
|
||||
const uniqueCustomers = [];
|
||||
const seenNames = new Set();
|
||||
|
||||
savedQuotes.forEach(quote => {
|
||||
const c = quote.customer;
|
||||
if (c && c.name && c.name.trim() !== '') {
|
||||
const normalized = c.name.trim().toLowerCase();
|
||||
if (!seenNames.has(normalized)) {
|
||||
seenNames.add(normalized);
|
||||
uniqueCustomers.push(c);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleSelectExisting = (e) => {
|
||||
if (e.target.value === "") return;
|
||||
const selected = uniqueCustomers[parseInt(e.target.value, 10)];
|
||||
if (selected) {
|
||||
onChange({ ...customer, ...selected });
|
||||
}
|
||||
// reset the select back to default after choosing
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card">
|
||||
<h2>Customer Information</h2>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<h2 style={{ margin: 0 }}>Customer Information</h2>
|
||||
{uniqueCustomers.length > 0 && (
|
||||
<div className="form-group" style={{ flexDirection: 'row', alignItems: 'center', gap: '0.5rem', flex: '0 0 auto' }}>
|
||||
<label htmlFor="existingCustomer" style={{ margin: 0 }}>Load Customer:</label>
|
||||
<select
|
||||
id="existingCustomer"
|
||||
onChange={handleSelectExisting}
|
||||
defaultValue=""
|
||||
style={{ padding: '0.5rem', width: '220px' }}
|
||||
>
|
||||
<option value="">-- Select Existing --</option>
|
||||
{uniqueCustomers.map((c, i) => (
|
||||
<option key={i} value={i}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">Name</label>
|
||||
<label htmlFor="name">Company / Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={customer.name}
|
||||
onChange={handleChange}
|
||||
placeholder="ABC Corp"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="contactName">Contact Name (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="contactName"
|
||||
name="contactName"
|
||||
value={customer.contactName || ''}
|
||||
onChange={handleChange}
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
@ -45,14 +98,27 @@ export default function CustomerForm({ customer, onChange }) {
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="province">Province / State</label>
|
||||
<input
|
||||
type="text"
|
||||
<select
|
||||
id="province"
|
||||
name="province"
|
||||
value={customer.province}
|
||||
onChange={handleChange}
|
||||
placeholder="ON"
|
||||
/>
|
||||
>
|
||||
<option value="">-- Select --</option>
|
||||
<option value="AB">Alberta (AB)</option>
|
||||
<option value="BC">British Columbia (BC)</option>
|
||||
<option value="MB">Manitoba (MB)</option>
|
||||
<option value="NB">New Brunswick (NB)</option>
|
||||
<option value="NL">Newfoundland and Labrador (NL)</option>
|
||||
<option value="NS">Nova Scotia (NS)</option>
|
||||
<option value="NT">Northwest Territories (NT)</option>
|
||||
<option value="NU">Nunavut (NU)</option>
|
||||
<option value="ON">Ontario (ON)</option>
|
||||
<option value="PE">Prince Edward Island (PE)</option>
|
||||
<option value="QC">Quebec (QC)</option>
|
||||
<option value="SK">Saskatchewan (SK)</option>
|
||||
<option value="YT">Yukon (YT)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="postalCode">Postal Code</label>
|
||||
|
||||
@ -4,21 +4,27 @@ import { Plus } from 'lucide-react';
|
||||
export default function ItemSelector({ items, onAddItem }) {
|
||||
const [selectedItemIndex, setSelectedItemIndex] = useState('');
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [discount, setDiscount] = useState('');
|
||||
|
||||
const handleAdd = () => {
|
||||
if (selectedItemIndex !== '' && quantity > 0) {
|
||||
const selectedItem = items[selectedItemIndex];
|
||||
onAddItem({ ...selectedItem, quantity: parseInt(quantity, 10) });
|
||||
onAddItem({
|
||||
...selectedItem,
|
||||
quantity: parseInt(quantity, 10),
|
||||
discount: parseFloat(discount) || 0
|
||||
});
|
||||
setSelectedItemIndex('');
|
||||
setQuantity(1);
|
||||
setDiscount('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card no-print">
|
||||
<h2>Add Items to Quote</h2>
|
||||
<div className="form-grid" style={{ alignItems: 'flex-end', gridTemplateColumns: '1fr 80px auto' }}>
|
||||
<div className="form-group">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem', alignItems: 'flex-end' }}>
|
||||
<div className="form-group" style={{ flex: '1 1 250px' }}>
|
||||
<label htmlFor="itemSelect">Select Product</label>
|
||||
<select
|
||||
id="itemSelect"
|
||||
@ -33,18 +39,36 @@ export default function ItemSelector({ items, onAddItem }) {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className="form-group" style={{ flex: '0 0 auto' }}>
|
||||
<label htmlFor="discount">Discount (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="discount"
|
||||
min="0"
|
||||
max="100"
|
||||
style={{ width: '100px' }}
|
||||
value={discount}
|
||||
onChange={(e) => setDiscount(e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: '0 0 auto' }}>
|
||||
<label htmlFor="quantity">Quantity</label>
|
||||
<input
|
||||
type="number"
|
||||
id="quantity"
|
||||
min="1"
|
||||
style={{ width: '80px' }}
|
||||
style={{ width: '100px' }}
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleAdd} disabled={selectedItemIndex === ''}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleAdd}
|
||||
disabled={selectedItemIndex === ''}
|
||||
style={{ flex: '0 0 auto', height: '44px' }}
|
||||
>
|
||||
<Plus size={20} /> Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
|
||||
export default function QuoteSummary({ items, customer, shippingCost, onShippingChange, onRemoveItem }) {
|
||||
export default function QuoteSummary({ items, customer, quoteId, quoteDate, shippingCost, onShippingChange, onRemoveItem, onEditItem, isWorkOrderMode }) {
|
||||
const provinceTaxRates = {
|
||||
'ON': 0.13, // Ontario (HST)
|
||||
'QC': 0.05, // Quebec (GST)
|
||||
@ -24,7 +24,13 @@ export default function QuoteSummary({ items, customer, shippingCost, onShipping
|
||||
};
|
||||
|
||||
const calculateSubtotal = () => {
|
||||
return items.reduce((total, item) => total + (item.Price * item.quantity), 0);
|
||||
return items.reduce((total, item) => {
|
||||
const qty = parseInt(item.quantity, 10) || 0;
|
||||
const price = parseFloat(item.Price) || 0;
|
||||
const itemTotal = price * qty;
|
||||
const discountAmount = itemTotal * ((item.discount || 0) / 100);
|
||||
return total + (itemTotal - discountAmount);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const subtotal = calculateSubtotal();
|
||||
@ -33,6 +39,16 @@ export default function QuoteSummary({ items, customer, shippingCost, onShipping
|
||||
const tax = (subtotal + shipping) * taxRate;
|
||||
const total = subtotal + shipping + tax;
|
||||
|
||||
const calculateTotalWeight = () => {
|
||||
return items.reduce((total, item) => {
|
||||
const qty = parseInt(item.quantity, 10) || 0;
|
||||
const weight = parseFloat(item.Weight) || 0;
|
||||
return total + (weight * qty);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const totalWeight = calculateTotalWeight();
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
||||
};
|
||||
@ -47,27 +63,111 @@ export default function QuoteSummary({ items, customer, shippingCost, onShipping
|
||||
|
||||
return (
|
||||
<div className="glass-card">
|
||||
<h2>Quote Summary</h2>
|
||||
<h2 className="no-print">{isWorkOrderMode ? 'Work Order Summary' : 'Quote Summary'}</h2>
|
||||
<div className="print-page-header">
|
||||
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
|
||||
<img src="/Logo.jpg" alt="Company Logo" style={{ height: '60px', marginBottom: '0.5rem' }} />
|
||||
<h1 style={{ fontSize: '1.4rem', color: 'black', margin: '0 0 0.25rem 0' }}>Little Beaver Earth Augers</h1>
|
||||
<p style={{ fontSize: '0.9rem', color: 'black', margin: 0 }}>{isWorkOrderMode ? 'Work Order' : 'Official Quote'}</p>
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', border: 'none', marginBottom: '1rem' }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={{ textAlign: 'left', verticalAlign: 'top', border: 'none', padding: 0 }}>
|
||||
<h3 style={{ margin: '0 0 0.25rem 0', fontSize: '1rem', color: 'black' }}>Quote For:</h3>
|
||||
<p style={{ margin: 0, color: 'black' }}>{customer.name || 'N/A'}</p>
|
||||
{customer.contactName && <p style={{ margin: 0, color: 'black' }}><strong>Attn:</strong> {customer.contactName}</p>}
|
||||
<p style={{ margin: 0, color: 'black' }}>{customer.address || 'N/A'}</p>
|
||||
<p style={{ margin: 0, color: 'black' }}>{customer.city ? `${customer.city}, ` : ''}{customer.province} {customer.postalCode}</p>
|
||||
<p style={{ margin: 0, color: 'black' }}>{customer.phone || 'N/A'}</p>
|
||||
<p style={{ margin: 0, color: 'black' }}>{customer.email || 'N/A'}</p>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', verticalAlign: 'top', border: 'none', padding: 0 }}>
|
||||
<h3 style={{ margin: '0 0 0.25rem 0', fontSize: '1rem', color: 'black' }}>{isWorkOrderMode ? 'Work Order Details:' : 'Quote Details:'}</h3>
|
||||
<p style={{ margin: 0, color: 'black' }}><strong>Date:</strong> {quoteDate}</p>
|
||||
<p style={{ margin: 0, color: 'black' }}><strong>{isWorkOrderMode ? 'Work Order #' : 'Quote #'}:</strong> {quoteId}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<table className="print-header-table">
|
||||
<thead>
|
||||
<tr className="print-continuation-row">
|
||||
<th colSpan={isWorkOrderMode ? 5 : 7} style={{ background: 'white', border: 'none', borderBottom: '2px solid black', padding: '0.5rem 0', fontWeight: 'normal', textAlign: 'center' }}>
|
||||
<span style={{ fontSize: '0.9rem', color: 'black' }}>
|
||||
{isWorkOrderMode ? 'Work Order' : 'Quote'} #{quoteId} | {quoteDate}
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Item ID</th>
|
||||
<th>Description</th>
|
||||
<th>Unit Price</th>
|
||||
{!isWorkOrderMode && <th>Unit Price</th>}
|
||||
{!isWorkOrderMode && <th>Discount</th>}
|
||||
<th>Quantity</th>
|
||||
<th>Total</th>
|
||||
{!isWorkOrderMode && <th>Total</th>}
|
||||
{isWorkOrderMode && <th style={{ width: '80px', textAlign: 'center' }}>Done</th>}
|
||||
<th className="no-print">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
{items.map((item, index) => {
|
||||
const qty = parseInt(item.quantity, 10) || 0;
|
||||
const price = parseFloat(item.Price) || 0;
|
||||
const itemTotal = price * qty;
|
||||
const discountAmount = itemTotal * ((item.discount || 0) / 100);
|
||||
const finalPrice = itemTotal - discountAmount;
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td>{item['Item ID']}</td>
|
||||
<td>{item.Description}</td>
|
||||
<td>{formatCurrency(item.Price)}</td>
|
||||
<td>{item.quantity}</td>
|
||||
<td>{formatCurrency(item.Price * item.quantity)}</td>
|
||||
{!isWorkOrderMode && <td>
|
||||
<span className="print-only-block">{formatCurrency(price)}</span>
|
||||
<div className="no-print" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ marginRight: '0.25rem' }}>$</span>
|
||||
<input
|
||||
type="number"
|
||||
value={item.Price === '' ? '' : price}
|
||||
onChange={(e) => onEditItem(index, 'Price', e.target.value === '' ? '' : parseFloat(e.target.value))}
|
||||
style={{ width: '80px', padding: '0.25rem' }}
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
</td>}
|
||||
{!isWorkOrderMode && <td>
|
||||
<span className="print-only-block">{item.discount ? `${item.discount}%` : '-'}</span>
|
||||
<div className="no-print" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={item.discount === '' ? '' : (item.discount || '')}
|
||||
onChange={(e) => onEditItem(index, 'discount', e.target.value === '' ? '' : parseFloat(e.target.value))}
|
||||
style={{ width: '60px', padding: '0.25rem' }}
|
||||
placeholder="0"
|
||||
/>
|
||||
<span style={{ marginLeft: '0.25rem' }}>%</span>
|
||||
</div>
|
||||
</td>}
|
||||
<td>
|
||||
<span className="print-only-block">{qty}</span>
|
||||
<div className="no-print">
|
||||
<input
|
||||
type="number"
|
||||
value={item.quantity === '' ? '' : qty}
|
||||
onChange={(e) => onEditItem(index, 'quantity', e.target.value === '' ? '' : parseInt(e.target.value, 10))}
|
||||
style={{ width: '60px', padding: '0.25rem' }}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
{!isWorkOrderMode && <td>{formatCurrency(finalPrice)}</td>}
|
||||
{isWorkOrderMode && (
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<div style={{ width: '20px', height: '20px', border: '1px solid black', margin: '0 auto' }} className="print-only-block"></div>
|
||||
<div style={{ width: '20px', height: '20px', border: '1px solid #ccc', margin: '0 auto' }} className="no-print" />
|
||||
</td>
|
||||
)}
|
||||
<td className="no-print">
|
||||
<button
|
||||
className="btn btn-icon btn-danger"
|
||||
@ -78,12 +178,19 @@ export default function QuoteSummary({ items, customer, shippingCost, onShipping
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="totals">
|
||||
<div className="total-row weight-row">
|
||||
<span>Total Weight:</span>
|
||||
<span>{totalWeight.toFixed(1)} lbs</span>
|
||||
</div>
|
||||
{!isWorkOrderMode && (
|
||||
<>
|
||||
<div className="total-row">
|
||||
<span>Subtotal:</span>
|
||||
<span>{formatCurrency(subtotal)}</span>
|
||||
@ -99,7 +206,7 @@ export default function QuoteSummary({ items, customer, shippingCost, onShipping
|
||||
style={{ width: '80px', padding: '0.25rem', fontSize: '1rem', textAlign: 'right' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="print-only-block" style={{ display: 'none' }}>{formatCurrency(shipping)}</span>
|
||||
<span className="print-only-block">{formatCurrency(shipping)}</span>
|
||||
</div>
|
||||
<div className="total-row">
|
||||
<span>Tax ({(taxRate * 100).toFixed(1)}%):</span>
|
||||
@ -109,6 +216,8 @@ export default function QuoteSummary({ items, customer, shippingCost, onShipping
|
||||
<span>Total:</span>
|
||||
<span>{formatCurrency(total)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -6,6 +6,22 @@ export default function SavedQuotesList({ savedQuotes, onLoadQuote, onDeleteQuot
|
||||
return null;
|
||||
}
|
||||
|
||||
const provinceTaxRates = {
|
||||
'ON': 0.13, // Ontario (HST)
|
||||
'QC': 0.05, // Quebec (GST)
|
||||
'NS': 0.15, // Nova Scotia (HST)
|
||||
'NB': 0.15, // New Brunswick (HST)
|
||||
'MB': 0.05, // Manitoba (GST)
|
||||
'BC': 0.05, // British Columbia (GST)
|
||||
'PE': 0.15, // Prince Edward Island (HST)
|
||||
'SK': 0.05, // Saskatchewan (GST)
|
||||
'AB': 0.05, // Alberta (GST)
|
||||
'NL': 0.15, // Newfoundland and Labrador (HST)
|
||||
'NT': 0.05, // Northwest Territories (GST)
|
||||
'YT': 0.05, // Yukon (GST)
|
||||
'NU': 0.05 // Nunavut (GST)
|
||||
};
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
||||
};
|
||||
@ -21,22 +37,48 @@ export default function SavedQuotesList({ savedQuotes, onLoadQuote, onDeleteQuot
|
||||
<th>Date</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Items</th>
|
||||
<th>Weight</th>
|
||||
<th>Total</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{savedQuotes.map((quote) => {
|
||||
// Calculate total for display
|
||||
const subtotal = quote.items.reduce((total, item) => total + (item.Price * item.quantity), 0);
|
||||
const total = subtotal + (subtotal * 0.08); // Assuming 8% tax as in QuoteSummary
|
||||
// Calculate subtotal with item discounts
|
||||
const subtotal = quote.items.reduce((total, item) => {
|
||||
const qty = parseInt(item.quantity, 10) || 0;
|
||||
const price = parseFloat(item.Price) || 0;
|
||||
const itemTotal = price * qty;
|
||||
const discountAmount = itemTotal * ((item.discount || 0) / 100);
|
||||
return total + (itemTotal - discountAmount);
|
||||
}, 0);
|
||||
|
||||
// Calculate shipping
|
||||
const shipping = parseFloat(quote.shippingCost) || 0;
|
||||
|
||||
// Calculate tax based on province
|
||||
const province = quote.customer?.province?.toUpperCase().trim();
|
||||
const taxRate = provinceTaxRates[province] || 0.13;
|
||||
const tax = (subtotal + shipping) * taxRate;
|
||||
const total = subtotal + shipping + tax;
|
||||
|
||||
// Calculate total weight
|
||||
const totalWeight = quote.items.reduce((total, item) => {
|
||||
const qty = parseInt(item.quantity, 10) || 0;
|
||||
const weight = parseFloat(item.Weight) || 0;
|
||||
return total + (weight * qty);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<tr key={quote.id}>
|
||||
<td>{quote.id}</td>
|
||||
<td>{quote.date}</td>
|
||||
<td>{quote.customer.name || 'N/A'}</td>
|
||||
<td>
|
||||
{quote.customer?.name || 'N/A'}
|
||||
{quote.customer?.contactName && <div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>Attn: {quote.customer.contactName}</div>}
|
||||
</td>
|
||||
<td>{quote.items.length}</td>
|
||||
<td>{totalWeight.toFixed(1)} lbs</td>
|
||||
<td>{formatCurrency(total)}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
|
||||
@ -217,6 +217,18 @@ td {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* New rule for weight row alignment */
|
||||
.weight-row {
|
||||
justify-content: flex-start;
|
||||
align-self: flex-start;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.weight-row span:first-child {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.total-row.grand-total {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
@ -227,11 +239,24 @@ td {
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
.print-continuation-header,
|
||||
.print-continuation-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.print-page-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
:root {
|
||||
--print-size: 9pt;
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 0.5in;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
@ -246,11 +271,40 @@ td {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.print-only-row {
|
||||
display: table-row !important;
|
||||
}
|
||||
|
||||
.print-page-header {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.print-continuation-row {
|
||||
display: table-row !important;
|
||||
}
|
||||
|
||||
.print-continuation-header {
|
||||
display: flex !important;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #ccc;
|
||||
font-size: 0.85rem;
|
||||
color: black;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: auto !important;
|
||||
min-height: auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
margin-bottom: 2rem !important;
|
||||
margin: 0 !important;
|
||||
background: transparent !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
@ -259,23 +313,7 @@ td {
|
||||
.container {
|
||||
padding: 0 !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 1.5rem !important;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
-webkit-text-fill-color: black !important;
|
||||
background: none !important;
|
||||
color: black !important;
|
||||
font-size: 1.8rem !important;
|
||||
margin-bottom: 0.2rem !important;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1rem !important;
|
||||
color: black !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
@ -284,6 +322,20 @@ td {
|
||||
|
||||
table {
|
||||
font-size: var(--print-size) !important;
|
||||
page-break-inside: auto !important;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: table-header-group !important;
|
||||
}
|
||||
|
||||
tfoot {
|
||||
display: table-footer-group !important;
|
||||
}
|
||||
|
||||
tr {
|
||||
page-break-inside: avoid !important;
|
||||
page-break-after: auto !important;
|
||||
}
|
||||
|
||||
table th {
|
||||
|
||||
Reference in New Issue
Block a user