Compare commits

..

12 Commits

Author SHA1 Message Date
7a56d5185b Upload files to "public" 2026-06-03 16:40:10 -04:00
4a623e76b4 added weight and updated prices 2026-06-03 15:49:38 -04:00
130130a7ed Print Fix 2026-05-19 08:20:30 -04:00
da80fcffe9 print update 2026-05-14 13:17:08 -04:00
1f25293750 added print for second page 2026-05-14 11:38:23 -04:00
1b875eb0b6 added edit feature 2026-04-23 14:36:24 -04:00
8a5092c56d add contact and workorder 2026-04-22 14:22:42 -04:00
4bebb04c5f reuse customer 2026-04-03 10:25:49 -04:00
0b4e60970e added discount 2026-03-20 16:52:20 -04:00
821cf379a9 Database added 2026-03-20 11:21:04 -04:00
2dec1f1f93 data update 2026-03-19 22:44:14 -04:00
3822452d8b fixed nix pack 2026-03-15 15:26:33 -04:00
17 changed files with 1082 additions and 203 deletions

View File

@ -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_]' }],
},
}
])

View File

@ -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
View 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);

View File

@ -1,3 +1,5 @@
[variables]
NIXPACKS_NODE_VERSION = '22'
[phases.setup]
nixPkgsArchive = 'fe1bc4be004dc12721ea2cb671b08a21de01c6976960ef8a1248798589679e16'
nixPkgsArchive = 'e4f44407a7a9a5561cd6b589a6d27e7a8f91a720'

147
package-lock.json generated
View File

@ -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",

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

Binary file not shown.

BIN
public/itemsold.xlsx Normal file

Binary file not shown.

View File

@ -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
}
]

110
server.js
View File

@ -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,40 +66,72 @@ const writeData = (data) => {
};
// GET all quotes
app.get('/api/quotes', (req, res) => {
const quotes = readData();
res.json(quotes);
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;
let quotes = readData();
const existingIndex = quotes.findIndex(q => q.id === newQuote.id);
if (existingIndex >= 0) {
quotes[existingIndex] = newQuote; // Update
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 {
quotes.push(newQuote); // Add new
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);
}
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
let quotes = readData();
const initialLength = quotes.length;
quotes = quotes.filter(q => q.id != idToDelete);
if (quotes.length < initialLength) {
writeData(quotes);
res.status(200).json({ message: "Quote deleted successfully" });
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 {
res.status(404).json({ message: "Quote not found" });
const idToDeleteFallback = parseInt(idToDelete) || idToDelete; // Handle number or string
let quotes = readData();
const initialLength = quotes.length;
quotes = quotes.filter(q => q.id != idToDeleteFallback);
if (quotes.length < initialLength) {
writeData(quotes);
res.status(200).json({ message: "Quote deleted successfully" });
} else {
res.status(404).json({ message: "Quote not found" });
}
}
});
@ -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.`);
});

View File

@ -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,10 +27,32 @@ function App() {
email: ''
});
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(() => {
generateNewQuoteId();
const loadSavedQuotes = async () => {
try {
const response = await fetch('/api/quotes');
if (response.ok) {
const data = await response.json();
setSavedQuotes(data);
}
} catch (error) {
console.error("Error loading quotes:", error);
}
};
loadSavedQuotes();
const handleAfterPrint = () => {
setIsWorkOrderMode(false);
};
window.addEventListener('afterprint', handleAfterPrint);
return () => window.removeEventListener('afterprint', handleAfterPrint);
}, []);
useEffect(() => {
@ -39,23 +63,6 @@ function App() {
fetchCatalog();
}, []);
const generateNewQuoteId = () => {
setQuoteId(Math.floor(Math.random() * 10000) + 1000);
setQuoteDate(new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }));
};
const loadSavedQuotes = async () => {
try {
const response = await fetch('/api/quotes');
if (response.ok) {
const data = await response.json();
setSavedQuotes(data);
}
} catch (error) {
console.error("Error loading quotes:", error);
}
};
const handleAddItem = (item) => {
setQuoteItems([...quoteItems, item]);
};
@ -66,8 +73,24 @@ function App() {
setQuoteItems(newItems);
};
const handlePrint = () => {
window.print();
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>
)}

View File

@ -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>

View File

@ -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>

View File

@ -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,68 +63,161 @@ 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} &nbsp;|&nbsp; {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) => (
<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>
<td className="no-print">
<button
className="btn btn-icon btn-danger"
onClick={() => onRemoveItem(index)}
title="Remove item"
>
<Trash2 size={16} />
</button>
</td>
</tr>
))}
{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>
{!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"
onClick={() => onRemoveItem(index)}
title="Remove item"
>
<Trash2 size={16} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="totals">
<div className="total-row">
<span>Subtotal:</span>
<span>{formatCurrency(subtotal)}</span>
</div>
<div className="total-row">
<span>Shipping:</span>
<div className="no-print" style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ marginRight: '0.25rem' }}>$</span>
<input
type="number"
value={shippingCost}
onChange={(e) => onShippingChange(e.target.value)}
style={{ width: '80px', padding: '0.25rem', fontSize: '1rem', textAlign: 'right' }}
/>
</div>
<span className="print-only-block" style={{ display: 'none' }}>{formatCurrency(shipping)}</span>
</div>
<div className="total-row">
<span>Tax ({(taxRate * 100).toFixed(1)}%):</span>
<span>{formatCurrency(tax)}</span>
</div>
<div className="total-row grand-total">
<span>Total:</span>
<span>{formatCurrency(total)}</span>
<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>
</div>
<div className="total-row">
<span>Shipping:</span>
<div className="no-print" style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ marginRight: '0.25rem' }}>$</span>
<input
type="number"
value={shippingCost}
onChange={(e) => onShippingChange(e.target.value)}
style={{ width: '80px', padding: '0.25rem', fontSize: '1rem', textAlign: 'right' }}
/>
</div>
<span className="print-only-block">{formatCurrency(shipping)}</span>
</div>
<div className="total-row">
<span>Tax ({(taxRate * 100).toFixed(1)}%):</span>
<span>{formatCurrency(tax)}</span>
</div>
<div className="total-row grand-total">
<span>Total:</span>
<span>{formatCurrency(total)}</span>
</div>
</>
)}
</div>
</div>
);

View File

@ -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' }}>

View File

@ -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 {