Compare commits
9 Commits
911b25d96b
...
2d5be21bf2
| Author | SHA1 | Date |
|---|---|---|
|
|
2d5be21bf2 | |
|
|
31f03b0d7c | |
|
|
03e0516c08 | |
|
|
52dcdce8bb | |
|
|
272f325d98 | |
|
|
c34f0391b3 | |
|
|
eb19b2785e | |
|
|
df1be3b823 | |
|
|
25da1a46a8 |
|
|
@ -1,2 +1,4 @@
|
||||||
.env
|
.env
|
||||||
*.png
|
*.png
|
||||||
|
node_modules
|
||||||
|
qbo_token.json
|
||||||
|
|
@ -24,6 +24,7 @@ RUN npm install --omit=dev
|
||||||
|
|
||||||
# Copy application files
|
# Copy application files
|
||||||
COPY server.js ./
|
COPY server.js ./
|
||||||
|
COPY qbo_helper.js ./
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|
||||||
# Create uploads directory
|
# Create uploads directory
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,18 @@ services:
|
||||||
DB_USER: ${DB_USER}
|
DB_USER: ${DB_USER}
|
||||||
DB_PASSWORD: ${DB_PASSWORD}
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
DB_NAME: ${DB_NAME}
|
DB_NAME: ${DB_NAME}
|
||||||
|
# --- NEU: QBO Variablen durchreichen ---
|
||||||
|
QBO_CLIENT_ID: ${QBO_CLIENT_ID}
|
||||||
|
QBO_CLIENT_SECRET: ${QBO_CLIENT_SECRET}
|
||||||
|
QBO_ENVIRONMENT: ${QBO_ENVIRONMENT}
|
||||||
|
QBO_REDIRECT_URI: ${QBO_REDIRECT_URI}
|
||||||
|
QBO_REALM_ID: ${QBO_REALM_ID}
|
||||||
|
QBO_ACCESS_TOKEN: ${QBO_ACCESS_TOKEN}
|
||||||
|
QBO_REFRESH_TOKEN: ${QBO_REFRESH_TOKEN}
|
||||||
volumes:
|
volumes:
|
||||||
- ./public/uploads:/app/public/uploads
|
- ./public/uploads:/app/public/uploads
|
||||||
- ./templates:/app/templates # NEU!
|
- ./templates:/app/templates # NEU!
|
||||||
|
- ./qbo_token.json:/app/qbo_token.json
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
require('dotenv').config();
|
||||||
|
const OAuthClient = require('intuit-oauth');
|
||||||
|
const { Client } = require('pg');
|
||||||
|
|
||||||
|
// --- KONFIGURATION ---
|
||||||
|
const totalLimit = null;
|
||||||
|
// ---------------------
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
clientId: process.env.QBO_CLIENT_ID,
|
||||||
|
clientSecret: process.env.QBO_CLIENT_SECRET,
|
||||||
|
environment: process.env.QBO_ENVIRONMENT || 'sandbox',
|
||||||
|
redirectUri: process.env.QBO_REDIRECT_URI,
|
||||||
|
token: {
|
||||||
|
// Wir brauchen initial nur den Refresh Token, Access holen wir uns neu
|
||||||
|
access_token: process.env.QBO_ACCESS_TOKEN,
|
||||||
|
refresh_token: process.env.QBO_REFRESH_TOKEN,
|
||||||
|
realmId: process.env.QBO_REALM_ID
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// SPEZIAL-CONFIG FÜR LOKALEN ZUGRIFF AUF DOCKER DB
|
||||||
|
const dbConfig = {
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
// WICHTIG: Lokal ist es immer localhost
|
||||||
|
host: 'localhost',
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
// WICHTIG: Laut deinem docker-compose mapst du 5433 auf 5432!
|
||||||
|
port: 5433,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function importCustomers() {
|
||||||
|
const oauthClient = new OAuthClient(config);
|
||||||
|
const pgClient = new Client(dbConfig);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// console.log("🔄 1. Versuche Token zu erneuern...");
|
||||||
|
// try {
|
||||||
|
// // Token Refresh erzwingen bevor wir starten
|
||||||
|
// const authResponse = await oauthClient.refresh();
|
||||||
|
// console.log("✅ Token erfolgreich erneuert!");
|
||||||
|
// // Optional: Das neue Token in der Session speichern, falls nötig
|
||||||
|
// } catch (tokenErr) {
|
||||||
|
// console.error("❌ Token Refresh fehlgeschlagen. Prüfe QBO_REFRESH_TOKEN in .env");
|
||||||
|
// console.error(tokenErr.originalMessage || tokenErr);
|
||||||
|
// return; // Abbruch
|
||||||
|
// }
|
||||||
|
|
||||||
|
console.log(`🔌 2. Verbinde zur DB (Port ${dbConfig.port})...`);
|
||||||
|
await pgClient.connect();
|
||||||
|
console.log(`✅ DB Verbunden.`);
|
||||||
|
|
||||||
|
// --- AB HIER DER NORMALE IMPORT ---
|
||||||
|
let startPosition = 1;
|
||||||
|
let totalProcessed = 0;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
let limitForThisBatch = 100;
|
||||||
|
if (totalLimit) {
|
||||||
|
const remaining = totalLimit - totalProcessed;
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
limitForThisBatch = Math.min(100, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `SELECT * FROM Customer STARTPOSITION ${startPosition} MAXRESULTS ${limitForThisBatch}`;
|
||||||
|
console.log(`📡 QBO Request: Hole ${limitForThisBatch} Kunden ab Pos ${startPosition}...`);
|
||||||
|
|
||||||
|
const baseUrl = config.environment === 'production'
|
||||||
|
? 'https://quickbooks.api.intuit.com/'
|
||||||
|
: 'https://sandbox-quickbooks.api.intuit.com/';
|
||||||
|
|
||||||
|
const response = await oauthClient.makeApiCall({
|
||||||
|
url: `${baseUrl}v3/company/${config.token.realmId}/query?query=${encodeURI(query)}`,
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
const customers = data.QueryResponse?.Customer || [];
|
||||||
|
|
||||||
|
console.log(`📥 QBO Response: ${customers.length} Kunden erhalten.`);
|
||||||
|
|
||||||
|
if (customers.length === 0) {
|
||||||
|
hasMore = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of customers) {
|
||||||
|
try {
|
||||||
|
const rawPhone = c.PrimaryPhone?.FreeFormNumber || "";
|
||||||
|
const formattedAccountNumber = rawPhone.replace(/\D/g, "");
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO customers (
|
||||||
|
name, line1, line2, line3, line4, city, state, zip_code,
|
||||||
|
account_number, email, phone, phone2, taxable, qbo_id, qbo_sync_token, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW())
|
||||||
|
ON CONFLICT (qbo_id) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
line1 = EXCLUDED.line1,
|
||||||
|
line2 = EXCLUDED.line2,
|
||||||
|
line3 = EXCLUDED.line3,
|
||||||
|
line4 = EXCLUDED.line4,
|
||||||
|
city = EXCLUDED.city,
|
||||||
|
state = EXCLUDED.state,
|
||||||
|
zip_code = EXCLUDED.zip_code,
|
||||||
|
email = EXCLUDED.email,
|
||||||
|
phone = EXCLUDED.phone,
|
||||||
|
phone2 = EXCLUDED.phone2,
|
||||||
|
qbo_sync_token = EXCLUDED.qbo_sync_token,
|
||||||
|
taxable = EXCLUDED.taxable,
|
||||||
|
updated_at = NOW();
|
||||||
|
`;
|
||||||
|
|
||||||
|
const values = [
|
||||||
|
c.CompanyName || c.DisplayName,
|
||||||
|
c.BillAddr?.Line1 || null,
|
||||||
|
c.BillAddr?.Line2 || null,
|
||||||
|
c.BillAddr?.Line3 || null,
|
||||||
|
c.BillAddr?.Line4 || null,
|
||||||
|
c.BillAddr?.City || null,
|
||||||
|
c.BillAddr?.CountrySubDivisionCode || null,
|
||||||
|
c.BillAddr?.PostalCode || null,
|
||||||
|
formattedAccountNumber || null,
|
||||||
|
c.PrimaryEmailAddr?.Address || null,
|
||||||
|
c.PrimaryPhone?.FreeFormNumber || null,
|
||||||
|
c.AlternatePhone?.FreeFormNumber || null,
|
||||||
|
c.Taxable || false,
|
||||||
|
c.Id,
|
||||||
|
c.SyncToken
|
||||||
|
];
|
||||||
|
|
||||||
|
await pgClient.query(sql, values);
|
||||||
|
totalProcessed++;
|
||||||
|
process.stdout.write(".");
|
||||||
|
} catch (rowError) {
|
||||||
|
console.error(`\n❌ DB Fehler bei Kunde ID ${c.Id}:`, rowError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
if (customers.length < limitForThisBatch) hasMore = false;
|
||||||
|
startPosition += customers.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Fertig! ${totalProcessed} Kunden verarbeitet.`);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("\n💀 FATAL ERROR:", e.message);
|
||||||
|
if(e.authResponse) console.log(JSON.stringify(e.authResponse, null, 2));
|
||||||
|
} finally {
|
||||||
|
await pgClient.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
importCustomers();
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,9 +8,12 @@
|
||||||
"dev": "nodemon server.js"
|
"dev": "nodemon server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"csv-parser": "^3.2.0",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"pg": "^8.13.1",
|
"intuit-oauth": "^4.2.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"pg": "^8.13.1",
|
||||||
"puppeteer": "^23.11.1"
|
"puppeteer": "^23.11.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
321
prod_backup.sql
321
prod_backup.sql
|
|
@ -1,321 +0,0 @@
|
||||||
--
|
|
||||||
-- PostgreSQL database dump
|
|
||||||
--
|
|
||||||
|
|
||||||
\restrict bxGU7dQ4DrNrHU2OuyEH16NHE6ZA8yFm2MADa6p2XI8qbowdWdtlaDeKSSp2NYx
|
|
||||||
|
|
||||||
-- Dumped from database version 15.15
|
|
||||||
-- Dumped by pg_dump version 15.15
|
|
||||||
|
|
||||||
SET statement_timeout = 0;
|
|
||||||
SET lock_timeout = 0;
|
|
||||||
SET idle_in_transaction_session_timeout = 0;
|
|
||||||
SET client_encoding = 'UTF8';
|
|
||||||
SET standard_conforming_strings = on;
|
|
||||||
SELECT pg_catalog.set_config('search_path', '', false);
|
|
||||||
SET check_function_bodies = false;
|
|
||||||
SET xmloption = content;
|
|
||||||
SET client_min_messages = warning;
|
|
||||||
SET row_security = off;
|
|
||||||
|
|
||||||
SET default_tablespace = '';
|
|
||||||
|
|
||||||
SET default_table_access_method = heap;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: customers; Type: TABLE; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.customers (
|
|
||||||
id integer NOT NULL,
|
|
||||||
name character varying(255) NOT NULL,
|
|
||||||
street character varying(255) NOT NULL,
|
|
||||||
city character varying(100) NOT NULL,
|
|
||||||
state character varying(2) NOT NULL,
|
|
||||||
zip_code character varying(10) NOT NULL,
|
|
||||||
account_number character varying(50),
|
|
||||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.customers OWNER TO quoteuser;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: customers_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.customers_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.customers_id_seq OWNER TO quoteuser;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: customers_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.customers_id_seq OWNED BY public.customers.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quote_items; Type: TABLE; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.quote_items (
|
|
||||||
id integer NOT NULL,
|
|
||||||
quote_id integer,
|
|
||||||
quantity character varying(20) NOT NULL,
|
|
||||||
description text NOT NULL,
|
|
||||||
rate character varying(50) NOT NULL,
|
|
||||||
amount character varying(50) NOT NULL,
|
|
||||||
is_tbd boolean DEFAULT false,
|
|
||||||
item_order integer NOT NULL,
|
|
||||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.quote_items OWNER TO quoteuser;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quote_items_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.quote_items_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.quote_items_id_seq OWNER TO quoteuser;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quote_items_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.quote_items_id_seq OWNED BY public.quote_items.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quotes; Type: TABLE; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TABLE public.quotes (
|
|
||||||
id integer NOT NULL,
|
|
||||||
quote_number character varying(50) NOT NULL,
|
|
||||||
customer_id integer,
|
|
||||||
quote_date date NOT NULL,
|
|
||||||
tax_exempt boolean DEFAULT false,
|
|
||||||
tax_rate numeric(5,2) DEFAULT 8.25,
|
|
||||||
subtotal numeric(10,2) DEFAULT 0,
|
|
||||||
tax_amount numeric(10,2) DEFAULT 0,
|
|
||||||
total numeric(10,2) DEFAULT 0,
|
|
||||||
has_tbd boolean DEFAULT false,
|
|
||||||
tbd_note text,
|
|
||||||
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.quotes OWNER TO quoteuser;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quotes_id_seq; Type: SEQUENCE; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE SEQUENCE public.quotes_id_seq
|
|
||||||
AS integer
|
|
||||||
START WITH 1
|
|
||||||
INCREMENT BY 1
|
|
||||||
NO MINVALUE
|
|
||||||
NO MAXVALUE
|
|
||||||
CACHE 1;
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE public.quotes_id_seq OWNER TO quoteuser;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quotes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER SEQUENCE public.quotes_id_seq OWNED BY public.quotes.id;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: customers id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.customers ALTER COLUMN id SET DEFAULT nextval('public.customers_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quote_items id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.quote_items ALTER COLUMN id SET DEFAULT nextval('public.quote_items_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quotes id; Type: DEFAULT; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.quotes ALTER COLUMN id SET DEFAULT nextval('public.quotes_id_seq'::regclass);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Data for Name: customers; Type: TABLE DATA; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
COPY public.customers (id, name, street, city, state, zip_code, account_number, created_at, updated_at) FROM stdin;
|
|
||||||
1 Braselton Development 5337 Yorktown Blvd. Suite 10-D Corpus Christi TX 78414 3617790060 2026-01-22 01:09:30.914655 2026-01-22 01:09:30.914655
|
|
||||||
2 Karen Menn 5134 Graford Place Corpus Christi TX 78413 3619933550 2026-01-22 01:19:49.357044 2026-01-22 01:49:16.051712
|
|
||||||
3 Hearing Aid Company of Texas 6468 Holly Road Corpus Christi TX 78412 3618143487 2026-01-22 03:33:56.090479 2026-01-22 03:33:56.090479
|
|
||||||
4 South Shore Christian Church 4710 S. Alameda Corpus Christi TX 78412 3619926391 2026-01-22 03:40:33.012646 2026-01-22 03:40:33.012646
|
|
||||||
5 JE Construction Services, LLC 7505 Up River Road Corpus Christi TX 78409 3612892901 2026-01-22 03:41:08.716604 2026-01-22 03:41:08.716604
|
|
||||||
6 John T. Thompson, DDS 4101 US-77 Corpus Christi TX 78410 3612423151 2026-01-30 20:50:22.987565 2026-01-30 21:06:23.354743
|
|
||||||
\.
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Data for Name: quote_items; Type: TABLE DATA; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
COPY public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) FROM stdin;
|
|
||||||
26 5 1 <p>HPE ProLiant MicroServer Gen11 Ultra Micro Tower Server - 1 x Intel Xeon E-2414, 32 GB DDR5 RAM</p> 1900 1900.00 f 0 2026-01-22 18:02:30.878526
|
|
||||||
27 5 2 <p>Western Digital 24TB WD Red Pro NAS Internal Hard Drive HDD</p> 850 1700.00 f 1 2026-01-22 18:02:30.878526
|
|
||||||
28 5 1 <ul><li>Off-site installation and base configuration of the TrueNAS system</li><li>Creation of storage pools, datasets, and network shares as specified\n</li><li>Configuration of users, user groups, and access permissions\nSetup of automated snapshots with defined retention and rollback capability\n</li><li>Configuration of cloud backups to iDrive360\nSetup of system monitoring and email notifications for proactive issue detection\n</li><li>Installation and configuration of AOMEI Backup on selected desktops and laptops, storing backups on designated TrueNAS shares</li></ul> 2250 2250.00 f 2 2026-01-22 18:02:30.878526
|
|
||||||
44 1 1 <ul><li>Dell OptiPlex 7010 SFF Desktop Intel Core i5-13600,14 Cores</li><li>16GB</li><li>Windows 11 Pro</li><li>Crucial - P310 2TB Internal SSD PCIe Gen 4 x4 NVMe M.2</li></ul> 1079 1079.00 f 0 2026-01-22 18:51:00.998206
|
|
||||||
45 1 1 <p>DisplayPort to HDMI cable 10ft</p> 20 20.00 f 1 2026-01-22 18:51:00.998206
|
|
||||||
46 1 3 <p>Setup and configure Dell OptiPlex 7010 off-site\nInstall all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network\nTransferred data from old computer including desktop items, documents, downloads, pictures, videos, browser bookmarks and favorites. \nSetup printing and scanning as required. Install customer requested software.Test all hardware for proper Operation</p> 125 375.00 f 2 2026-01-22 18:51:00.998206
|
|
||||||
47 2 1 <p>Lenovo Yoga 7 82YN 16" i5-1335U 1.3GHz 16GB RAM 512GB SSD</p> 500 500.00 f 0 2026-01-22 18:54:57.288474
|
|
||||||
48 2 2 <p>Setup and configure Lenovo yoga 7 82YN 16" i5-1335U 1.3GHz 16GB RAM 512GB SSD off-site. Install all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network. Transferred data from old computer including desktop items, documents, downloads, pictures, videos, browser bookmarks and favorites. Setup printing and scanning as required. Install customer requested software. Test all hardware for proper operation.</p> 125 250.00 f 1 2026-01-22 18:54:57.288474
|
|
||||||
49 4 1 <ul><li>Dell OptiPlex 7020 Plus Tower Desktop PC – Core i7-14700 </li><li>32GB DDR5 RAM, </li><li>2 TB SSD M.2 PCIe Gen4 TLC, </li><li>NVIDIA® GeForce RTX™ 5050</li></ul> 2080 2080.00 f 0 2026-01-22 20:00:28.631846
|
|
||||||
50 4 2 <p>Setup and configure Lenovo Yoga as needed. Install all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network. Transfer data from old computer including desktop items, documents, downloads, pictures, videos, browser bookmarks and favorites. Setup printing and scanning as required. Install customer requested software. Test all hardware for proper operation.</p> 125 250.00 f 1 2026-01-22 20:00:28.631846
|
|
||||||
59 3 3 <ul><li><strong>Lenovo </strong>ThinkPad P16s Mobile Workstation</li><li><strong>Processor:</strong> Intel® Core™ Ultra 7 155H</li><li><strong>Graphics Card:</strong> NVIDIA RTX™ 500 Ada Generation Laptop GPU, <strong>4 GB GDDR6</strong></li><li><strong>Memory: 64 GB DDR5-5600 MT/s</strong></li><li><strong>Storage: 1 TB SSD</strong> M.2 2280 PCIe Gen4 </li><li><strong>Display: </strong>16" WQUXGA (3840 × 2400) OLED</li><li><strong>Operating System:</strong> Windows 11 Pro 64-bit</li><li><strong>1 Year Warranty</strong></li><li><strong>(This device is new, not refurbished)</strong></li></ul> 1949 5847.00 f 0 2026-01-26 18:41:05.501558
|
|
||||||
60 3 <p>Setup and configure Lenovo Laptops as needed. Install all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network. Setup printing and scanning as required. Install customer requested software. Test all hardware for proper operation.</p> 125 TBD t 1 2026-01-26 18:41:05.501558
|
|
||||||
74 7 1 <p>Dell Optiplex 7010 (or similar) Tower configured with Intel Core i5-13500 processor, 16GB RAM, 512GB solid state drive and Windows 11 Professional. Refurbished with a one year warranty.\t</p> 725.00 725.00 f 0 2026-01-30 21:04:38.661279
|
|
||||||
75 7 3 <p>Delivery and installation of Dell Tower PC. Setup on local network and install requested software. Install and configure local and network printers. Transfer requested data from existing PC and verify proper operation of all hardware. </p><p><br></p><p>Microsoft Office (if required) is not included in this quote.</p> 125 375.00 f 1 2026-01-30 21:04:38.661279
|
|
||||||
76 6 1 <ul><li>Dell Tower Computer configured with Intel Core Ultra 5 235 processor, 16GB RAM, 512GB SSD and Windows 11 Professional. New with One Year Warranty.</li></ul> 1325.00 1325.00 f 0 2026-01-30 21:07:26.820637
|
|
||||||
77 6 3 <p>Delivery and installation of new Dell Tower PC. Setup on local network and install requested software. Install and configure local and network printers. Transfer requested data from existing PC and verify proper operation of all hardware. </p><p><br></p><p>Microsoft Office (if required) is not included in this quote.</p> 125.00 375.00 f 1 2026-01-30 21:07:26.820637
|
|
||||||
\.
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Data for Name: quotes; Type: TABLE DATA; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
COPY public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) FROM stdin;
|
|
||||||
5 2026-01-0005 5 2026-01-22 f 8.25 5850.00 482.63 6332.63 f 2026-01-22 16:28:42.374654 2026-01-22 18:02:30.878526
|
|
||||||
1 2026-01-0001 2 2026-01-22 f 8.25 1474.00 121.61 1595.61 f 2026-01-22 01:34:06.558046 2026-01-22 18:51:00.998206
|
|
||||||
2 2026-01-0002 3 2026-01-22 f 8.25 750.00 61.88 811.88 f 2026-01-22 03:35:15.021729 2026-01-22 18:54:57.288474
|
|
||||||
4 2026-01-0004 4 2026-01-22 f 8.25 2330.00 192.23 2522.23 f 2026-01-22 03:45:56.686598 2026-01-22 20:00:28.631846
|
|
||||||
3 2026-01-0003 1 2026-01-26 f 8.25 5847.00 482.38 6329.38 t Total excludes labor charges which will be determined based on actual time required. 2026-01-22 03:36:47.795674 2026-01-26 18:41:05.501558
|
|
||||||
7 2026-01-0007 6 2026-01-30 f 8.25 1100.00 90.75 1190.75 f 2026-01-30 21:01:43.538202 2026-01-30 21:04:38.661279
|
|
||||||
6 2026-01-0006 6 2026-01-30 f 8.25 1700.00 140.25 1840.25 f 2026-01-30 20:58:23.014874 2026-01-30 21:07:26.820637
|
|
||||||
\.
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: customers_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
SELECT pg_catalog.setval('public.customers_id_seq', 6, true);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quote_items_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
SELECT pg_catalog.setval('public.quote_items_id_seq', 77, true);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quotes_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
SELECT pg_catalog.setval('public.quotes_id_seq', 7, true);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: customers customers_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.customers
|
|
||||||
ADD CONSTRAINT customers_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quote_items quote_items_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.quote_items
|
|
||||||
ADD CONSTRAINT quote_items_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quotes quotes_pkey; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.quotes
|
|
||||||
ADD CONSTRAINT quotes_pkey PRIMARY KEY (id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quotes quotes_quote_number_key; Type: CONSTRAINT; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.quotes
|
|
||||||
ADD CONSTRAINT quotes_quote_number_key UNIQUE (quote_number);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: idx_quote_items_quote_id; Type: INDEX; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE INDEX idx_quote_items_quote_id ON public.quote_items USING btree (quote_id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: idx_quotes_customer_id; Type: INDEX; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE INDEX idx_quotes_customer_id ON public.quotes USING btree (customer_id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: idx_quotes_quote_number; Type: INDEX; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE INDEX idx_quotes_quote_number ON public.quotes USING btree (quote_number);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quote_items quote_items_quote_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.quote_items
|
|
||||||
ADD CONSTRAINT quote_items_quote_id_fkey FOREIGN KEY (quote_id) REFERENCES public.quotes(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quotes quotes_customer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
ALTER TABLE ONLY public.quotes
|
|
||||||
ADD CONSTRAINT quotes_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES public.customers(id);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- PostgreSQL database dump complete
|
|
||||||
--
|
|
||||||
|
|
||||||
\unrestrict bxGU7dQ4DrNrHU2OuyEH16NHE6ZA8yFm2MADa6p2XI8qbowdWdtlaDeKSSp2NYx
|
|
||||||
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
--
|
|
||||||
-- PostgreSQL database dump
|
|
||||||
--
|
|
||||||
|
|
||||||
\restrict KCbrUeHdJ7srnFlBFWbQWdZ6A6bdMlTKPXbmEoc5qE3gaNBouFxTyfvdD9oETV4
|
|
||||||
|
|
||||||
-- Dumped from database version 15.15
|
|
||||||
-- Dumped by pg_dump version 15.15
|
|
||||||
|
|
||||||
SET statement_timeout = 0;
|
|
||||||
SET lock_timeout = 0;
|
|
||||||
SET idle_in_transaction_session_timeout = 0;
|
|
||||||
SET client_encoding = 'UTF8';
|
|
||||||
SET standard_conforming_strings = on;
|
|
||||||
SELECT pg_catalog.set_config('search_path', '', false);
|
|
||||||
SET check_function_bodies = false;
|
|
||||||
SET xmloption = content;
|
|
||||||
SET client_min_messages = warning;
|
|
||||||
SET row_security = off;
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Data for Name: customers; Type: TABLE DATA; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
INSERT INTO public.customers (id, name, street, city, state, zip_code, account_number, created_at, updated_at) VALUES (1, 'Braselton Development', '5337 Yorktown Blvd. Suite 10-D', 'Corpus Christi', 'TX', '78414', '3617790060', '2026-01-22 01:09:30.914655', '2026-01-22 01:09:30.914655');
|
|
||||||
INSERT INTO public.customers (id, name, street, city, state, zip_code, account_number, created_at, updated_at) VALUES (2, 'Karen Menn', '5134 Graford Place', 'Corpus Christi', 'TX', '78413', '3619933550', '2026-01-22 01:19:49.357044', '2026-01-22 01:49:16.051712');
|
|
||||||
INSERT INTO public.customers (id, name, street, city, state, zip_code, account_number, created_at, updated_at) VALUES (3, 'Hearing Aid Company of Texas', '6468 Holly Road', 'Corpus Christi', 'TX', '78412', '3618143487', '2026-01-22 03:33:56.090479', '2026-01-22 03:33:56.090479');
|
|
||||||
INSERT INTO public.customers (id, name, street, city, state, zip_code, account_number, created_at, updated_at) VALUES (4, 'South Shore Christian Church', '4710 S. Alameda', 'Corpus Christi', 'TX', '78412', '3619926391', '2026-01-22 03:40:33.012646', '2026-01-22 03:40:33.012646');
|
|
||||||
INSERT INTO public.customers (id, name, street, city, state, zip_code, account_number, created_at, updated_at) VALUES (5, 'JE Construction Services, LLC', '7505 Up River Road', 'Corpus Christi', 'TX', '78409', '3612892901', '2026-01-22 03:41:08.716604', '2026-01-22 03:41:08.716604');
|
|
||||||
INSERT INTO public.customers (id, name, street, city, state, zip_code, account_number, created_at, updated_at) VALUES (6, 'John T. Thompson, DDS', '4101 US-77', 'Corpus Christi', 'TX', '78410', '3612423151', '2026-01-30 20:50:22.987565', '2026-01-30 21:06:23.354743');
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Data for Name: quotes; Type: TABLE DATA; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
INSERT INTO public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) VALUES (5, '2026-01-0005', 5, '2026-01-22', false, 8.25, 5850.00, 482.63, 6332.63, false, '', '2026-01-22 16:28:42.374654', '2026-01-22 18:02:30.878526');
|
|
||||||
INSERT INTO public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) VALUES (1, '2026-01-0001', 2, '2026-01-22', false, 8.25, 1474.00, 121.61, 1595.61, false, '', '2026-01-22 01:34:06.558046', '2026-01-22 18:51:00.998206');
|
|
||||||
INSERT INTO public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) VALUES (2, '2026-01-0002', 3, '2026-01-22', false, 8.25, 750.00, 61.88, 811.88, false, '', '2026-01-22 03:35:15.021729', '2026-01-22 18:54:57.288474');
|
|
||||||
INSERT INTO public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) VALUES (4, '2026-01-0004', 4, '2026-01-22', false, 8.25, 2330.00, 192.23, 2522.23, false, '', '2026-01-22 03:45:56.686598', '2026-01-22 20:00:28.631846');
|
|
||||||
INSERT INTO public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) VALUES (3, '2026-01-0003', 1, '2026-01-26', false, 8.25, 5847.00, 482.38, 6329.38, true, 'Total excludes labor charges which will be determined based on actual time required.', '2026-01-22 03:36:47.795674', '2026-01-26 18:41:05.501558');
|
|
||||||
INSERT INTO public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) VALUES (7, '2026-01-0007', 6, '2026-01-30', false, 8.25, 1100.00, 90.75, 1190.75, false, '', '2026-01-30 21:01:43.538202', '2026-01-30 21:04:38.661279');
|
|
||||||
INSERT INTO public.quotes (id, quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, tbd_note, created_at, updated_at) VALUES (6, '2026-01-0006', 6, '2026-01-30', false, 8.25, 1700.00, 140.25, 1840.25, false, '', '2026-01-30 20:58:23.014874', '2026-01-30 21:07:26.820637');
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Data for Name: quote_items; Type: TABLE DATA; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (26, 5, '1', '<p>HPE ProLiant MicroServer Gen11 Ultra Micro Tower Server - 1 x Intel Xeon E-2414, 32 GB DDR5 RAM</p>', '1900', '1900.00', false, 0, '2026-01-22 18:02:30.878526');
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (27, 5, '2', '<p>Western Digital 24TB WD Red Pro NAS Internal Hard Drive HDD</p>', '850', '1700.00', false, 1, '2026-01-22 18:02:30.878526');
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (28, 5, '1', '<ul><li>Off-site installation and base configuration of the TrueNAS system</li><li>Creation of storage pools, datasets, and network shares as specified
|
|
||||||
</li><li>Configuration of users, user groups, and access permissions
|
|
||||||
Setup of automated snapshots with defined retention and rollback capability
|
|
||||||
</li><li>Configuration of cloud backups to iDrive360
|
|
||||||
Setup of system monitoring and email notifications for proactive issue detection
|
|
||||||
</li><li>Installation and configuration of AOMEI Backup on selected desktops and laptops, storing backups on designated TrueNAS shares</li></ul>', '2250', '2250.00', false, 2, '2026-01-22 18:02:30.878526');
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (44, 1, '1', '<ul><li>Dell OptiPlex 7010 SFF Desktop Intel Core i5-13600,14 Cores</li><li>16GB</li><li>Windows 11 Pro</li><li>Crucial - P310 2TB Internal SSD PCIe Gen 4 x4 NVMe M.2</li></ul>', '1079', '1079.00', false, 0, '2026-01-22 18:51:00.998206');
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (45, 1, '1', '<p>DisplayPort to HDMI cable 10ft</p>', '20', '20.00', false, 1, '2026-01-22 18:51:00.998206');
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (46, 1, '3', '<p>Setup and configure Dell OptiPlex 7010 off-site
|
|
||||||
Install all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network
|
|
||||||
Transferred data from old computer including desktop items, documents, downloads, pictures, videos, browser bookmarks and favorites.
|
|
||||||
Setup printing and scanning as required. Install customer requested software.Test all hardware for proper Operation</p>', '125', '375.00', false, 2, '2026-01-22 18:51:00.998206');
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (47, 2, '1', '<p>Lenovo Yoga 7 82YN 16" i5-1335U 1.3GHz 16GB RAM 512GB SSD</p>', '500', '500.00', false, 0, '2026-01-22 18:54:57.288474');
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (48, 2, '2', '<p>Setup and configure Lenovo yoga 7 82YN 16" i5-1335U 1.3GHz 16GB RAM 512GB SSD off-site. Install all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network. Transferred data from old computer including desktop items, documents, downloads, pictures, videos, browser bookmarks and favorites. Setup printing and scanning as required. Install customer requested software. Test all hardware for proper operation.</p>', '125', '250.00', false, 1, '2026-01-22 18:54:57.288474');
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (49, 4, '1', '<ul><li>Dell OptiPlex 7020 Plus Tower Desktop PC – Core i7-14700 </li><li>32GB DDR5 RAM, </li><li>2 TB SSD M.2 PCIe Gen4 TLC, </li><li>NVIDIA® GeForce RTX™ 5050</li></ul>', '2080', '2080.00', false, 0, '2026-01-22 20:00:28.631846');
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (50, 4, '2', '<p>Setup and configure Lenovo Yoga as needed. Install all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network. Transfer data from old computer including desktop items, documents, downloads, pictures, videos, browser bookmarks and favorites. Setup printing and scanning as required. Install customer requested software. Test all hardware for proper operation.</p>', '125', '250.00', false, 1, '2026-01-22 20:00:28.631846');
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (59, 3, '3', '<ul><li><strong>Lenovo </strong>ThinkPad P16s Mobile Workstation</li><li><strong>Processor:</strong> Intel® Core™ Ultra 7 155H</li><li><strong>Graphics Card:</strong> NVIDIA RTX™ 500 Ada Generation Laptop GPU, <strong>4 GB GDDR6</strong></li><li><strong>Memory: 64 GB DDR5-5600 MT/s</strong></li><li><strong>Storage: 1 TB SSD</strong> M.2 2280 PCIe Gen4 </li><li><strong>Display: </strong>16" WQUXGA (3840 × 2400) OLED</li><li><strong>Operating System:</strong> Windows 11 Pro 64-bit</li><li><strong>1 Year Warranty</strong></li><li><strong>(This device is new, not refurbished)</strong></li></ul>', '1949', '5847.00', false, 0, '2026-01-26 18:41:05.501558');
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (60, 3, '', '<p>Setup and configure Lenovo Laptops as needed. Install all Lenovo drivers and latest Windows updates. Deliver to site and setup on local network. Setup printing and scanning as required. Install customer requested software. Test all hardware for proper operation.</p>', '125', 'TBD', true, 1, '2026-01-26 18:41:05.501558');
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (74, 7, '1', '<p>Dell Optiplex 7010 (or similar) Tower configured with Intel Core i5-13500 processor, 16GB RAM, 512GB solid state drive and Windows 11 Professional. Refurbished with a one year warranty. </p>', '725.00', '725.00', false, 0, '2026-01-30 21:04:38.661279');
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (75, 7, '3', '<p>Delivery and installation of Dell Tower PC. Setup on local network and install requested software. Install and configure local and network printers. Transfer requested data from existing PC and verify proper operation of all hardware. </p><p><br></p><p>Microsoft Office (if required) is not included in this quote.</p>', '125', '375.00', false, 1, '2026-01-30 21:04:38.661279');
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (76, 6, '1', '<ul><li>Dell Tower Computer configured with Intel Core Ultra 5 235 processor, 16GB RAM, 512GB SSD and Windows 11 Professional. New with One Year Warranty.</li></ul>', '1325.00', '1325.00', false, 0, '2026-01-30 21:07:26.820637');
|
|
||||||
INSERT INTO public.quote_items (id, quote_id, quantity, description, rate, amount, is_tbd, item_order, created_at) VALUES (77, 6, '3', '<p>Delivery and installation of new Dell Tower PC. Setup on local network and install requested software. Install and configure local and network printers. Transfer requested data from existing PC and verify proper operation of all hardware. </p><p><br></p><p>Microsoft Office (if required) is not included in this quote.</p>', '125.00', '375.00', false, 1, '2026-01-30 21:07:26.820637');
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: customers_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
SELECT pg_catalog.setval('public.customers_id_seq', 6, true);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quote_items_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
SELECT pg_catalog.setval('public.quote_items_id_seq', 77, true);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- Name: quotes_id_seq; Type: SEQUENCE SET; Schema: public; Owner: quoteuser
|
|
||||||
--
|
|
||||||
|
|
||||||
SELECT pg_catalog.setval('public.quotes_id_seq', 7, true);
|
|
||||||
|
|
||||||
|
|
||||||
--
|
|
||||||
-- PostgreSQL database dump complete
|
|
||||||
--
|
|
||||||
|
|
||||||
\unrestrict KCbrUeHdJ7srnFlBFWbQWdZ6A6bdMlTKPXbmEoc5qE3gaNBouFxTyfvdD9oETV4
|
|
||||||
|
|
||||||
447
public/app.js
447
public/app.js
|
|
@ -18,15 +18,21 @@ function customerSearch(type) {
|
||||||
highlighted: 0,
|
highlighted: 0,
|
||||||
|
|
||||||
get filteredCustomers() {
|
get filteredCustomers() {
|
||||||
|
// Wenn Suche leer ist: ALLE Kunden zurückgeben (kein .slice mehr!)
|
||||||
if (!this.search) {
|
if (!this.search) {
|
||||||
return customers.slice(0, 50); // Show first 50 if no search
|
return customers;
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchLower = this.search.toLowerCase();
|
const searchLower = this.search.toLowerCase();
|
||||||
|
|
||||||
|
// Filtern: Sucht im Namen, Line1, Stadt oder Account Nummer
|
||||||
|
// Auch hier: Kein .slice mehr, damit alle Ergebnisse (z.B. alle mit 'C') angezeigt werden
|
||||||
return customers.filter(c =>
|
return customers.filter(c =>
|
||||||
c.name.toLowerCase().includes(searchLower) ||
|
(c.name || '').toLowerCase().includes(searchLower) ||
|
||||||
c.city.toLowerCase().includes(searchLower) ||
|
(c.line1 || '').toLowerCase().includes(searchLower) ||
|
||||||
|
(c.city || '').toLowerCase().includes(searchLower) ||
|
||||||
(c.account_number && c.account_number.includes(searchLower))
|
(c.account_number && c.account_number.includes(searchLower))
|
||||||
).slice(0, 50); // Limit to 50 results
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
selectCustomer(customer) {
|
selectCustomer(customer) {
|
||||||
|
|
@ -72,10 +78,22 @@ window.customerSearch = customerSearch;
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadCustomers();
|
loadCustomers();
|
||||||
loadQuotes();
|
loadQuotes();
|
||||||
loadInvoices();
|
//loadInvoices();
|
||||||
setDefaultDate();
|
setDefaultDate();
|
||||||
checkCurrentLogo();
|
checkCurrentLogo();
|
||||||
|
|
||||||
|
// *** FIX 3: Gespeicherten Tab wiederherstellen (oder 'quotes' als Default) ***
|
||||||
|
const savedTab = localStorage.getItem('activeTab') || 'quotes';
|
||||||
|
showTab(savedTab);
|
||||||
|
|
||||||
|
// Hash-basierte Tab-Navigation (z.B. nach OAuth Redirect /#settings)
|
||||||
|
if (window.location.hash) {
|
||||||
|
const hashTab = window.location.hash.replace('#', '');
|
||||||
|
if (['quotes', 'invoices', 'customers', 'settings'].includes(hashTab)) {
|
||||||
|
showTab(hashTab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Setup form handlers
|
// Setup form handlers
|
||||||
document.getElementById('customer-form').addEventListener('submit', handleCustomerSubmit);
|
document.getElementById('customer-form').addEventListener('submit', handleCustomerSubmit);
|
||||||
document.getElementById('quote-form').addEventListener('submit', handleQuoteSubmit);
|
document.getElementById('quote-form').addEventListener('submit', handleQuoteSubmit);
|
||||||
|
|
@ -102,6 +120,9 @@ function showTab(tabName) {
|
||||||
document.getElementById(`${tabName}-tab`).classList.remove('hidden');
|
document.getElementById(`${tabName}-tab`).classList.remove('hidden');
|
||||||
document.getElementById(`tab-${tabName}`).classList.add('bg-blue-800');
|
document.getElementById(`tab-${tabName}`).classList.add('bg-blue-800');
|
||||||
|
|
||||||
|
// *** FIX 3: Tab-Auswahl persistieren ***
|
||||||
|
localStorage.setItem('activeTab', tabName);
|
||||||
|
|
||||||
if (tabName === 'quotes') {
|
if (tabName === 'quotes') {
|
||||||
loadQuotes();
|
loadQuotes();
|
||||||
} else if (tabName === 'invoices') {
|
} else if (tabName === 'invoices') {
|
||||||
|
|
@ -197,17 +218,32 @@ async function loadCustomers() {
|
||||||
|
|
||||||
function renderCustomers() {
|
function renderCustomers() {
|
||||||
const tbody = document.getElementById('customers-list');
|
const tbody = document.getElementById('customers-list');
|
||||||
tbody.innerHTML = customers.map(customer => `
|
tbody.innerHTML = customers.map(customer => {
|
||||||
|
// Logik: Line 1-4 zusammenbauen
|
||||||
|
// filter(Boolean) entfernt null/undefined/leere Strings
|
||||||
|
const lines = [customer.line1, customer.line2, customer.line3, customer.line4].filter(Boolean);
|
||||||
|
|
||||||
|
// City, State, Zip anhängen
|
||||||
|
const cityStateZip = [customer.city, customer.state, customer.zip_code].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
// Alles zusammenfügen
|
||||||
|
let fullAddress = lines.join(', ');
|
||||||
|
if (cityStateZip) {
|
||||||
|
fullAddress += (fullAddress ? ', ' : '') + cityStateZip;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${customer.name}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${customer.name}</td>
|
||||||
<td class="px-6 py-4 text-sm text-gray-500">${customer.street}, ${customer.city}, ${customer.state} ${customer.zip_code}</td>
|
<td class="px-6 py-4 text-sm text-gray-500">${fullAddress || '-'}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${customer.account_number || '-'}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${customer.account_number || '-'}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||||
<button onclick="editCustomer(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
<button onclick="editCustomer(${customer.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||||||
<button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
<button onclick="deleteCustomer(${customer.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCustomerModal(customerId = null) {
|
function openCustomerModal(customerId = null) {
|
||||||
|
|
@ -218,17 +254,29 @@ function openCustomerModal(customerId = null) {
|
||||||
if (customerId) {
|
if (customerId) {
|
||||||
title.textContent = 'Edit Customer';
|
title.textContent = 'Edit Customer';
|
||||||
const customer = customers.find(c => c.id === customerId);
|
const customer = customers.find(c => c.id === customerId);
|
||||||
|
|
||||||
document.getElementById('customer-id').value = customer.id;
|
document.getElementById('customer-id').value = customer.id;
|
||||||
document.getElementById('customer-name').value = customer.name;
|
document.getElementById('customer-name').value = customer.name;
|
||||||
document.getElementById('customer-street').value = customer.street;
|
|
||||||
document.getElementById('customer-city').value = customer.city;
|
// Neue Felder befüllen
|
||||||
document.getElementById('customer-state').value = customer.state;
|
document.getElementById('customer-line1').value = customer.line1 || '';
|
||||||
document.getElementById('customer-zip').value = customer.zip_code;
|
document.getElementById('customer-line2').value = customer.line2 || '';
|
||||||
|
document.getElementById('customer-line3').value = customer.line3 || '';
|
||||||
|
document.getElementById('customer-line4').value = customer.line4 || '';
|
||||||
|
|
||||||
|
document.getElementById('customer-city').value = customer.city || '';
|
||||||
|
document.getElementById('customer-state').value = customer.state || '';
|
||||||
|
document.getElementById('customer-zip').value = customer.zip_code || '';
|
||||||
document.getElementById('customer-account').value = customer.account_number || '';
|
document.getElementById('customer-account').value = customer.account_number || '';
|
||||||
|
document.getElementById('customer-email').value = customer.email || '';
|
||||||
|
document.getElementById('customer-phone').value = customer.phone || '';
|
||||||
|
|
||||||
|
document.getElementById('customer-taxable').checked = customer.taxable !== false;
|
||||||
} else {
|
} else {
|
||||||
title.textContent = 'New Customer';
|
title.textContent = 'New Customer';
|
||||||
document.getElementById('customer-form').reset();
|
document.getElementById('customer-form').reset();
|
||||||
document.getElementById('customer-id').value = '';
|
document.getElementById('customer-id').value = '';
|
||||||
|
document.getElementById('customer-taxable').checked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
modal.classList.add('active');
|
modal.classList.add('active');
|
||||||
|
|
@ -244,11 +292,21 @@ async function handleCustomerSubmit(e) {
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
name: document.getElementById('customer-name').value,
|
name: document.getElementById('customer-name').value,
|
||||||
street: document.getElementById('customer-street').value,
|
|
||||||
|
// Neue Felder auslesen
|
||||||
|
line1: document.getElementById('customer-line1').value,
|
||||||
|
line2: document.getElementById('customer-line2').value,
|
||||||
|
line3: document.getElementById('customer-line3').value,
|
||||||
|
line4: document.getElementById('customer-line4').value,
|
||||||
|
|
||||||
city: document.getElementById('customer-city').value,
|
city: document.getElementById('customer-city').value,
|
||||||
state: document.getElementById('customer-state').value.toUpperCase(),
|
state: document.getElementById('customer-state').value.toUpperCase(),
|
||||||
zip_code: document.getElementById('customer-zip').value,
|
zip_code: document.getElementById('customer-zip').value,
|
||||||
account_number: document.getElementById('customer-account').value
|
account_number: document.getElementById('customer-account').value,
|
||||||
|
email: document.getElementById('customer-email')?.value || '',
|
||||||
|
phone: document.getElementById('customer-phone')?.value || '',
|
||||||
|
phone2: '', // Erstmal leer lassen, falls kein Feld im Formular ist
|
||||||
|
taxable: document.getElementById('customer-taxable')?.checked ?? true
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -399,80 +457,65 @@ function addQuoteItem(item = null) {
|
||||||
itemDiv.id = `quote-item-${itemId}`;
|
itemDiv.id = `quote-item-${itemId}`;
|
||||||
itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`);
|
itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`);
|
||||||
|
|
||||||
// Get preview text
|
// Preview Text logic
|
||||||
const previewQty = item ? item.quantity : '';
|
const previewQty = item ? item.quantity : '';
|
||||||
const previewAmount = item ? item.amount : '$0.00';
|
const previewAmount = item ? item.amount : '$0.00';
|
||||||
let previewDesc = 'New item';
|
let previewDesc = 'New item';
|
||||||
if (item && item.description) {
|
if (item && item.description) {
|
||||||
const temp = document.createElement('div');
|
const temp = document.createElement('div');
|
||||||
temp.innerHTML = item.description;
|
temp.innerHTML = item.description;
|
||||||
previewDesc = temp.textContent.substring(0, 60) + (temp.textContent.length > 60 ? '...' : '');
|
previewDesc = temp.textContent.substring(0, 50) + (temp.textContent.length > 50 ? '...' : '');
|
||||||
}
|
}
|
||||||
|
// Preview Type Logic (NEU)
|
||||||
|
const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts';
|
||||||
|
|
||||||
itemDiv.innerHTML = `
|
itemDiv.innerHTML = `
|
||||||
<!-- Accordion Header -->
|
|
||||||
<div class="flex items-center p-4">
|
<div class="flex items-center p-4">
|
||||||
<!-- Move Buttons (Left) - Outside accordion click area -->
|
|
||||||
<div class="flex flex-col mr-3" onclick="event.stopPropagation()">
|
<div class="flex flex-col mr-3" onclick="event.stopPropagation()">
|
||||||
<button type="button" onclick="moveQuoteItemUp(${itemId})"
|
<button type="button" onclick="moveQuoteItemUp(${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1">↑</button>
|
||||||
class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1"
|
<button type="button" onclick="moveQuoteItemDown(${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none">↓</button>
|
||||||
title="Move Up">
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="moveQuoteItemDown(${itemId})"
|
|
||||||
class="text-blue-600 hover:text-blue-800 text-lg leading-none"
|
|
||||||
title="Move Down">
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Accordion Toggle & Content (Center) -->
|
|
||||||
<div @click="open = !open" class="flex items-center flex-1 cursor-pointer hover:bg-gray-50 rounded px-3 py-2">
|
<div @click="open = !open" class="flex items-center flex-1 cursor-pointer hover:bg-gray-50 rounded px-3 py-2">
|
||||||
<svg x-show="!open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg x-show="!open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
<svg x-show="open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
|
||||||
</svg>
|
|
||||||
<svg x-show="open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<span class="text-sm font-medium mr-4">Qty: <span class="item-qty-preview">${previewQty}</span></span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
<span class="text-xs font-bold px-2 py-1 rounded bg-gray-200 text-gray-700 mr-4 item-type-preview">${typeLabel}</span>
|
||||||
</svg>
|
|
||||||
<span class="text-sm font-medium">Qty: <span class="item-qty-preview">${previewQty}</span></span>
|
|
||||||
<span class="text-sm text-gray-600 flex-1 truncate mx-4 item-desc-preview">${previewDesc}</span>
|
<span class="text-sm text-gray-600 flex-1 truncate mx-4 item-desc-preview">${previewDesc}</span>
|
||||||
<span class="text-sm font-semibold item-amount-preview">${previewAmount}</span>
|
<span class="text-sm font-semibold item-amount-preview">${previewAmount}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Button (Right) - Outside accordion click area -->
|
<button type="button" onclick="removeQuoteItem(${itemId}); event.stopPropagation();" class="ml-3 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">×</button>
|
||||||
<button type="button" onclick="removeQuoteItem(${itemId}); event.stopPropagation();"
|
|
||||||
class="ml-3 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Accordion Content -->
|
|
||||||
<div x-show="open" x-transition class="p-4 border-t border-gray-200">
|
<div x-show="open" x-transition class="p-4 border-t border-gray-200">
|
||||||
<div class="grid grid-cols-12 gap-3 items-start">
|
<div class="grid grid-cols-12 gap-3 items-start">
|
||||||
<div class="col-span-1">
|
<div class="col-span-1">
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
|
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
|
||||||
<input type="text" data-item="${itemId}" data-field="quantity"
|
<input type="text" data-item="${itemId}" data-field="quantity" value="${item ? item.quantity : ''}" class="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||||
value="${item ? item.quantity : ''}"
|
|
||||||
class="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-6">
|
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="block text-xs font-medium text-gray-700 mb-1">Type (Internal)</label>
|
||||||
|
<select data-item="${itemId}" data-field="qbo_item_id" class="w-full px-2 py-2 border border-gray-300 rounded-md text-sm bg-white" onchange="updateItemPreview(this.closest('[id^=quote-item-]'), ${itemId})">
|
||||||
|
<option value="9" ${item && item.qbo_item_id == '9' ? 'selected' : ''}>Parts</option>
|
||||||
|
<option value="5" ${item && item.qbo_item_id == '5' ? 'selected' : ''}>Labor</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-4">
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
|
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
|
||||||
<div data-item="${itemId}" data-field="description"
|
<div data-item="${itemId}" data-field="description" class="quote-item-description-editor border border-gray-300 rounded-md bg-white" style="min-height: 60px;"></div>
|
||||||
class="quote-item-description-editor border border-gray-300 rounded-md bg-white"
|
|
||||||
style="min-height: 60px;">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Rate</label>
|
<label class="block text-xs font-medium text-gray-700 mb-1">Rate</label>
|
||||||
<input type="text" data-item="${itemId}" data-field="rate"
|
<input type="text" data-item="${itemId}" data-field="rate" value="${item ? item.rate : ''}" class="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||||
value="${item ? item.rate : ''}"
|
|
||||||
class="quote-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-3">
|
<div class="col-span-3">
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Amount</label>
|
<label class="block text-xs font-medium text-gray-700 mb-1">Amount</label>
|
||||||
<input type="text" data-item="${itemId}" data-field="amount"
|
<input type="text" data-item="${itemId}" data-field="amount" value="${item ? item.amount : ''}" class="quote-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||||
value="${item ? item.amount : ''}"
|
|
||||||
class="quote-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -480,31 +523,18 @@ function addQuoteItem(item = null) {
|
||||||
|
|
||||||
itemsDiv.appendChild(itemDiv);
|
itemsDiv.appendChild(itemDiv);
|
||||||
|
|
||||||
// Initialize Quill editor
|
// Quill Init
|
||||||
const editorDiv = itemDiv.querySelector('.quote-item-description-editor');
|
const editorDiv = itemDiv.querySelector('.quote-item-description-editor');
|
||||||
const quill = new Quill(editorDiv, {
|
const quill = new Quill(editorDiv, {
|
||||||
theme: 'snow',
|
theme: 'snow',
|
||||||
modules: {
|
modules: { toolbar: [['bold', 'italic', 'underline'], [{ 'list': 'ordered'}, { 'list': 'bullet' }], ['clean']] }
|
||||||
toolbar: [
|
|
||||||
['bold', 'italic', 'underline'],
|
|
||||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
|
||||||
['clean']
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (item && item.description) {
|
|
||||||
quill.root.innerHTML = item.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
quill.on('text-change', () => {
|
|
||||||
updateItemPreview(itemDiv, itemId);
|
|
||||||
updateQuoteTotals();
|
|
||||||
});
|
});
|
||||||
|
if (item && item.description) quill.root.innerHTML = item.description;
|
||||||
|
|
||||||
|
quill.on('text-change', () => { updateItemPreview(itemDiv, itemId); updateQuoteTotals(); });
|
||||||
editorDiv.quillInstance = quill;
|
editorDiv.quillInstance = quill;
|
||||||
|
|
||||||
// Auto-calculate amount and update preview
|
// Auto-calculate logic
|
||||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||||
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||||
|
|
@ -521,10 +551,7 @@ function addQuoteItem(item = null) {
|
||||||
|
|
||||||
qtyInput.addEventListener('input', calculateAmount);
|
qtyInput.addEventListener('input', calculateAmount);
|
||||||
rateInput.addEventListener('input', calculateAmount);
|
rateInput.addEventListener('input', calculateAmount);
|
||||||
amountInput.addEventListener('input', () => {
|
amountInput.addEventListener('input', () => { updateItemPreview(itemDiv, itemId); updateQuoteTotals(); });
|
||||||
updateItemPreview(itemDiv, itemId);
|
|
||||||
updateQuoteTotals();
|
|
||||||
});
|
|
||||||
|
|
||||||
updateItemPreview(itemDiv, itemId);
|
updateItemPreview(itemDiv, itemId);
|
||||||
updateQuoteTotals();
|
updateQuoteTotals();
|
||||||
|
|
@ -533,18 +560,25 @@ function addQuoteItem(item = null) {
|
||||||
function updateItemPreview(itemDiv, itemId) {
|
function updateItemPreview(itemDiv, itemId) {
|
||||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||||
|
const typeInput = itemDiv.querySelector('[data-field="qbo_item_id"]'); // NEU
|
||||||
const editorDiv = itemDiv.querySelector('.quote-item-description-editor');
|
const editorDiv = itemDiv.querySelector('.quote-item-description-editor');
|
||||||
|
|
||||||
const qtyPreview = itemDiv.querySelector('.item-qty-preview');
|
const qtyPreview = itemDiv.querySelector('.item-qty-preview');
|
||||||
const descPreview = itemDiv.querySelector('.item-desc-preview');
|
const descPreview = itemDiv.querySelector('.item-desc-preview');
|
||||||
const amountPreview = itemDiv.querySelector('.item-amount-preview');
|
const amountPreview = itemDiv.querySelector('.item-amount-preview');
|
||||||
|
const typePreview = itemDiv.querySelector('.item-type-preview'); // NEU
|
||||||
|
|
||||||
if (qtyPreview) qtyPreview.textContent = qtyInput.value || '0';
|
if (qtyPreview) qtyPreview.textContent = qtyInput.value || '0';
|
||||||
if (amountPreview) amountPreview.textContent = amountInput.value || '$0.00';
|
if (amountPreview) amountPreview.textContent = amountInput.value || '$0.00';
|
||||||
|
|
||||||
|
// NEU: Update Type Label
|
||||||
|
if (typePreview && typeInput) {
|
||||||
|
typePreview.textContent = typeInput.value == '5' ? 'Labor' : 'Parts';
|
||||||
|
}
|
||||||
|
|
||||||
if (descPreview && editorDiv.quillInstance) {
|
if (descPreview && editorDiv.quillInstance) {
|
||||||
const plainText = editorDiv.quillInstance.getText().trim();
|
const plainText = editorDiv.quillInstance.getText().trim();
|
||||||
const preview = plainText.substring(0, 60) + (plainText.length > 60 ? '...' : '');
|
const preview = plainText.substring(0, 50) + (plainText.length > 50 ? '...' : '');
|
||||||
descPreview.textContent = preview || 'New item';
|
descPreview.textContent = preview || 'New item';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -612,13 +646,14 @@ function getQuoteItems() {
|
||||||
|
|
||||||
const item = {
|
const item = {
|
||||||
quantity: div.querySelector('[data-field="quantity"]').value,
|
quantity: div.querySelector('[data-field="quantity"]').value,
|
||||||
|
// NEU: ID holen
|
||||||
|
qbo_item_id: div.querySelector('[data-field="qbo_item_id"]').value,
|
||||||
description: descriptionHTML,
|
description: descriptionHTML,
|
||||||
rate: div.querySelector('[data-field="rate"]').value,
|
rate: div.querySelector('[data-field="rate"]').value,
|
||||||
amount: div.querySelector('[data-field="amount"]').value
|
amount: div.querySelector('[data-field="amount"]').value
|
||||||
};
|
};
|
||||||
items.push(item);
|
items.push(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -720,30 +755,37 @@ async function fetchNextInvoiceNumber() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadInvoices() {
|
async function loadInvoices() {
|
||||||
|
// Wird vom invoice-view.js Modul überschrieben (window.loadInvoices)
|
||||||
|
// Dieser Fallback lädt die Daten falls das Modul noch nicht geladen ist
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/invoices');
|
const response = await fetch('/api/invoices');
|
||||||
invoices = await response.json();
|
invoices = await response.json();
|
||||||
|
// Falls das Modul geladen ist, nutze dessen Renderer
|
||||||
|
if (window.invoiceView) {
|
||||||
|
window.invoiceView.renderInvoiceView();
|
||||||
|
} else {
|
||||||
renderInvoices();
|
renderInvoices();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading invoices:', error);
|
console.error('Error loading invoices:', error);
|
||||||
alert('Error loading invoices');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderInvoices() {
|
function renderInvoices() {
|
||||||
|
if (window.invoiceView) {
|
||||||
|
window.invoiceView.renderInvoiceView();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Minimaler Fallback falls Modul nicht geladen
|
||||||
const tbody = document.getElementById('invoices-list');
|
const tbody = document.getElementById('invoices-list');
|
||||||
tbody.innerHTML = invoices.map(invoice => `
|
tbody.innerHTML = invoices.map(invoice => `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${invoice.invoice_number}</td>
|
<td class="px-6 py-4 text-sm font-medium text-gray-900">${invoice.invoice_number}</td>
|
||||||
<td class="px-6 py-4 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
|
<td class="px-6 py-4 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
|
<td class="px-6 py-4 text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
|
<td class="px-6 py-4 text-sm text-gray-500">${invoice.terms}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">$${parseFloat(invoice.total).toFixed(2)}</td>
|
<td class="px-6 py-4 text-sm font-semibold">$${parseFloat(invoice.total).toFixed(2)}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
<td class="px-6 py-4 text-sm">Loading module...</td>
|
||||||
<button onclick="viewInvoicePDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>
|
|
||||||
<button onclick="editInvoice(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
|
||||||
<button onclick="deleteInvoice(${invoice.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
@ -823,80 +865,65 @@ function addInvoiceItem(item = null) {
|
||||||
itemDiv.id = `invoice-item-${itemId}`;
|
itemDiv.id = `invoice-item-${itemId}`;
|
||||||
itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`);
|
itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`);
|
||||||
|
|
||||||
// Get preview text
|
// Preview Text logic
|
||||||
const previewQty = item ? item.quantity : '';
|
const previewQty = item ? item.quantity : '';
|
||||||
const previewAmount = item ? item.amount : '$0.00';
|
const previewAmount = item ? item.amount : '$0.00';
|
||||||
let previewDesc = 'New item';
|
let previewDesc = 'New item';
|
||||||
if (item && item.description) {
|
if (item && item.description) {
|
||||||
const temp = document.createElement('div');
|
const temp = document.createElement('div');
|
||||||
temp.innerHTML = item.description;
|
temp.innerHTML = item.description;
|
||||||
previewDesc = temp.textContent.substring(0, 60) + (temp.textContent.length > 60 ? '...' : '');
|
previewDesc = temp.textContent.substring(0, 50) + (temp.textContent.length > 50 ? '...' : '');
|
||||||
}
|
}
|
||||||
|
// Preview Type Logic
|
||||||
|
const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts';
|
||||||
|
|
||||||
itemDiv.innerHTML = `
|
itemDiv.innerHTML = `
|
||||||
<!-- Accordion Header -->
|
|
||||||
<div class="flex items-center p-4">
|
<div class="flex items-center p-4">
|
||||||
<!-- Move Buttons (Left) - Outside accordion click area -->
|
|
||||||
<div class="flex flex-col mr-3" onclick="event.stopPropagation()">
|
<div class="flex flex-col mr-3" onclick="event.stopPropagation()">
|
||||||
<button type="button" onclick="moveInvoiceItemUp(${itemId})"
|
<button type="button" onclick="moveInvoiceItemUp(${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1">↑</button>
|
||||||
class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1"
|
<button type="button" onclick="moveInvoiceItemDown(${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none">↓</button>
|
||||||
title="Move Up">
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="moveInvoiceItemDown(${itemId})"
|
|
||||||
class="text-blue-600 hover:text-blue-800 text-lg leading-none"
|
|
||||||
title="Move Down">
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Accordion Toggle & Content (Center) -->
|
|
||||||
<div @click="open = !open" class="flex items-center flex-1 cursor-pointer hover:bg-gray-50 rounded px-3 py-2">
|
<div @click="open = !open" class="flex items-center flex-1 cursor-pointer hover:bg-gray-50 rounded px-3 py-2">
|
||||||
<svg x-show="!open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg x-show="!open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
<svg x-show="open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
|
||||||
</svg>
|
|
||||||
<svg x-show="open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<span class="text-sm font-medium mr-4">Qty: <span class="item-qty-preview">${previewQty}</span></span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
<span class="text-xs font-bold px-2 py-1 rounded bg-gray-200 text-gray-700 mr-4 item-type-preview">${typeLabel}</span>
|
||||||
</svg>
|
|
||||||
<span class="text-sm font-medium">Qty: <span class="item-qty-preview">${previewQty}</span></span>
|
|
||||||
<span class="text-sm text-gray-600 flex-1 truncate mx-4 item-desc-preview">${previewDesc}</span>
|
<span class="text-sm text-gray-600 flex-1 truncate mx-4 item-desc-preview">${previewDesc}</span>
|
||||||
<span class="text-sm font-semibold item-amount-preview">${previewAmount}</span>
|
<span class="text-sm font-semibold item-amount-preview">${previewAmount}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Button (Right) - Outside accordion click area -->
|
<button type="button" onclick="removeInvoiceItem(${itemId}); event.stopPropagation();" class="ml-3 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">×</button>
|
||||||
<button type="button" onclick="removeInvoiceItem(${itemId}); event.stopPropagation();"
|
|
||||||
class="ml-3 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Accordion Content -->
|
|
||||||
<div x-show="open" x-transition class="p-4 border-t border-gray-200">
|
<div x-show="open" x-transition class="p-4 border-t border-gray-200">
|
||||||
<div class="grid grid-cols-12 gap-3 items-start">
|
<div class="grid grid-cols-12 gap-3 items-start">
|
||||||
<div class="col-span-1">
|
<div class="col-span-1">
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
|
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
|
||||||
<input type="text" data-item="${itemId}" data-field="quantity"
|
<input type="text" data-item="${itemId}" data-field="quantity" value="${item ? item.quantity : ''}" class="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||||
value="${item ? item.quantity : ''}"
|
|
||||||
class="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-6">
|
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="block text-xs font-medium text-gray-700 mb-1">Type (Internal)</label>
|
||||||
|
<select data-item="${itemId}" data-field="qbo_item_id" class="w-full px-2 py-2 border border-gray-300 rounded-md text-sm bg-white" onchange="updateInvoiceItemPreview(this.closest('[id^=invoice-item-]'), ${itemId})">
|
||||||
|
<option value="9" ${item && item.qbo_item_id == '9' ? 'selected' : ''}>Parts</option>
|
||||||
|
<option value="5" ${item && item.qbo_item_id == '5' ? 'selected' : ''}>Labor</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-4">
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
|
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
|
||||||
<div data-item="${itemId}" data-field="description"
|
<div data-item="${itemId}" data-field="description" class="invoice-item-description-editor border border-gray-300 rounded-md bg-white" style="min-height: 60px;"></div>
|
||||||
class="invoice-item-description-editor border border-gray-300 rounded-md bg-white"
|
|
||||||
style="min-height: 60px;">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Rate</label>
|
<label class="block text-xs font-medium text-gray-700 mb-1">Rate</label>
|
||||||
<input type="text" data-item="${itemId}" data-field="rate"
|
<input type="text" data-item="${itemId}" data-field="rate" value="${item ? item.rate : ''}" class="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||||
value="${item ? item.rate : ''}"
|
|
||||||
class="invoice-item-input w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-3">
|
<div class="col-span-3">
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Amount</label>
|
<label class="block text-xs font-medium text-gray-700 mb-1">Amount</label>
|
||||||
<input type="text" data-item="${itemId}" data-field="amount"
|
<input type="text" data-item="${itemId}" data-field="amount" value="${item ? item.amount : ''}" class="invoice-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||||
value="${item ? item.amount : ''}"
|
|
||||||
class="invoice-item-amount w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -904,31 +931,18 @@ function addInvoiceItem(item = null) {
|
||||||
|
|
||||||
itemsDiv.appendChild(itemDiv);
|
itemsDiv.appendChild(itemDiv);
|
||||||
|
|
||||||
// Initialize Quill editor
|
// Quill Init (wie vorher)
|
||||||
const editorDiv = itemDiv.querySelector('.invoice-item-description-editor');
|
const editorDiv = itemDiv.querySelector('.invoice-item-description-editor');
|
||||||
const quill = new Quill(editorDiv, {
|
const quill = new Quill(editorDiv, {
|
||||||
theme: 'snow',
|
theme: 'snow',
|
||||||
modules: {
|
modules: { toolbar: [['bold', 'italic', 'underline'], [{ 'list': 'ordered'}, { 'list': 'bullet' }], ['clean']] }
|
||||||
toolbar: [
|
|
||||||
['bold', 'italic', 'underline'],
|
|
||||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
|
||||||
['clean']
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (item && item.description) {
|
|
||||||
quill.root.innerHTML = item.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
quill.on('text-change', () => {
|
|
||||||
updateInvoiceItemPreview(itemDiv, itemId);
|
|
||||||
updateInvoiceTotals();
|
|
||||||
});
|
});
|
||||||
|
if (item && item.description) quill.root.innerHTML = item.description;
|
||||||
|
|
||||||
|
quill.on('text-change', () => { updateInvoiceItemPreview(itemDiv, itemId); updateInvoiceTotals(); });
|
||||||
editorDiv.quillInstance = quill;
|
editorDiv.quillInstance = quill;
|
||||||
|
|
||||||
// Auto-calculate amount and update preview
|
// Auto-calculate logic (wie vorher)
|
||||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||||
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||||
|
|
@ -945,10 +959,7 @@ function addInvoiceItem(item = null) {
|
||||||
|
|
||||||
qtyInput.addEventListener('input', calculateAmount);
|
qtyInput.addEventListener('input', calculateAmount);
|
||||||
rateInput.addEventListener('input', calculateAmount);
|
rateInput.addEventListener('input', calculateAmount);
|
||||||
amountInput.addEventListener('input', () => {
|
amountInput.addEventListener('input', () => { updateInvoiceItemPreview(itemDiv, itemId); updateInvoiceTotals(); });
|
||||||
updateInvoiceItemPreview(itemDiv, itemId);
|
|
||||||
updateInvoiceTotals();
|
|
||||||
});
|
|
||||||
|
|
||||||
updateInvoiceItemPreview(itemDiv, itemId);
|
updateInvoiceItemPreview(itemDiv, itemId);
|
||||||
updateInvoiceTotals();
|
updateInvoiceTotals();
|
||||||
|
|
@ -957,18 +968,25 @@ function addInvoiceItem(item = null) {
|
||||||
function updateInvoiceItemPreview(itemDiv, itemId) {
|
function updateInvoiceItemPreview(itemDiv, itemId) {
|
||||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||||
|
const typeInput = itemDiv.querySelector('[data-field="qbo_item_id"]'); // NEU
|
||||||
const editorDiv = itemDiv.querySelector('.invoice-item-description-editor');
|
const editorDiv = itemDiv.querySelector('.invoice-item-description-editor');
|
||||||
|
|
||||||
const qtyPreview = itemDiv.querySelector('.item-qty-preview');
|
const qtyPreview = itemDiv.querySelector('.item-qty-preview');
|
||||||
const descPreview = itemDiv.querySelector('.item-desc-preview');
|
const descPreview = itemDiv.querySelector('.item-desc-preview');
|
||||||
const amountPreview = itemDiv.querySelector('.item-amount-preview');
|
const amountPreview = itemDiv.querySelector('.item-amount-preview');
|
||||||
|
const typePreview = itemDiv.querySelector('.item-type-preview'); // NEU
|
||||||
|
|
||||||
if (qtyPreview) qtyPreview.textContent = qtyInput.value || '0';
|
if (qtyPreview) qtyPreview.textContent = qtyInput.value || '0';
|
||||||
if (amountPreview) amountPreview.textContent = amountInput.value || '$0.00';
|
if (amountPreview) amountPreview.textContent = amountInput.value || '$0.00';
|
||||||
|
|
||||||
|
// NEU: Update Type Label
|
||||||
|
if (typePreview && typeInput) {
|
||||||
|
typePreview.textContent = typeInput.value == '5' ? 'Labor' : 'Parts';
|
||||||
|
}
|
||||||
|
|
||||||
if (descPreview && editorDiv.quillInstance) {
|
if (descPreview && editorDiv.quillInstance) {
|
||||||
const plainText = editorDiv.quillInstance.getText().trim();
|
const plainText = editorDiv.quillInstance.getText().trim();
|
||||||
const preview = plainText.substring(0, 60) + (plainText.length > 60 ? '...' : '');
|
const preview = plainText.substring(0, 50) + (plainText.length > 50 ? '...' : '');
|
||||||
descPreview.textContent = preview || 'New item';
|
descPreview.textContent = preview || 'New item';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1025,19 +1043,18 @@ function getInvoiceItems() {
|
||||||
|
|
||||||
itemDivs.forEach(div => {
|
itemDivs.forEach(div => {
|
||||||
const descEditor = div.querySelector('.invoice-item-description-editor');
|
const descEditor = div.querySelector('.invoice-item-description-editor');
|
||||||
const descriptionHTML = descEditor && descEditor.quillInstance
|
const descriptionHTML = descEditor && descEditor.quillInstance ? descEditor.quillInstance.root.innerHTML : '';
|
||||||
? descEditor.quillInstance.root.innerHTML
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const item = {
|
const item = {
|
||||||
quantity: div.querySelector('[data-field="quantity"]').value,
|
quantity: div.querySelector('[data-field="quantity"]').value,
|
||||||
|
// NEU: ID holen
|
||||||
|
qbo_item_id: div.querySelector('[data-field="qbo_item_id"]').value,
|
||||||
description: descriptionHTML,
|
description: descriptionHTML,
|
||||||
rate: div.querySelector('[data-field="rate"]').value,
|
rate: div.querySelector('[data-field="rate"]').value,
|
||||||
amount: div.querySelector('[data-field="amount"]').value
|
amount: div.querySelector('[data-field="amount"]').value
|
||||||
};
|
};
|
||||||
items.push(item);
|
items.push(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1112,5 +1129,117 @@ async function deleteInvoice(id) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewInvoicePDF(id) {
|
function viewInvoicePDF(id) {
|
||||||
|
if (window.invoiceView) {
|
||||||
|
window.invoiceView.viewPDF(id);
|
||||||
|
} else {
|
||||||
window.open(`/api/invoices/${id}/pdf`, '_blank');
|
window.open(`/api/invoices/${id}/pdf`, '_blank');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkQboOverdue() {
|
||||||
|
const btn = document.querySelector('button[onclick="checkQboOverdue()"]');
|
||||||
|
const resultDiv = document.getElementById('qbo-result');
|
||||||
|
const tbody = document.getElementById('qbo-result-list');
|
||||||
|
|
||||||
|
// UI Loading State
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '⏳ Connecting to QBO...';
|
||||||
|
btn.disabled = true;
|
||||||
|
resultDiv.classList.add('hidden');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/qbo/overdue');
|
||||||
|
const invoices = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
if (invoices.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4" class="px-4 py-4 text-center text-gray-500">✅ Good news! No overdue invoices found older than 30 days.</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = invoices.map(inv => `
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-900">${inv.DocNumber || '(No Num)'}</td>
|
||||||
|
<td class="px-4 py-2 text-gray-600">${inv.CustomerRef?.name || 'Unknown'}</td>
|
||||||
|
<td class="px-4 py-2 text-red-600 font-medium">${inv.DueDate}</td>
|
||||||
|
<td class="px-4 py-2 text-right font-bold text-gray-800">$${inv.Balance}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
alert(`Success! Connection working. Found ${invoices.length} overdue invoices.`);
|
||||||
|
} else {
|
||||||
|
throw new Error(invoices.error || 'Unknown error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('QBO Test Error:', error);
|
||||||
|
alert('❌ Connection Test Failed: ' + error.message);
|
||||||
|
tbody.innerHTML = `<tr><td colspan="4" class="px-4 py-4 text-center text-red-600">Error: ${error.message}</td></tr>`;
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importFromQBO() {
|
||||||
|
if (!confirm(
|
||||||
|
'Alle unbezahlten Rechnungen aus QBO importieren?\n\n' +
|
||||||
|
'• Bereits importierte werden übersprungen\n' +
|
||||||
|
'• Nur Kunden die lokal verknüpft sind\n\n' +
|
||||||
|
'Fortfahren?'
|
||||||
|
)) return;
|
||||||
|
|
||||||
|
const btn = document.querySelector('button[onclick="importFromQBO()"]');
|
||||||
|
const resultDiv = document.getElementById('qbo-import-result');
|
||||||
|
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '⏳ Importiere aus QBO...';
|
||||||
|
btn.disabled = true;
|
||||||
|
resultDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/qbo/import-unpaid', { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
let html = `<div class="p-4 rounded-lg ${result.imported > 0 ? 'bg-green-50 border border-green-200' : 'bg-blue-50 border border-blue-200'}">`;
|
||||||
|
html += `<p class="font-semibold text-gray-800 mb-2">Import abgeschlossen</p>`;
|
||||||
|
html += `<ul class="text-sm text-gray-700 space-y-1">`;
|
||||||
|
html += `<li>✅ <strong>${result.imported}</strong> Rechnungen importiert</li>`;
|
||||||
|
|
||||||
|
if (result.skipped > 0) {
|
||||||
|
html += `<li>⏭️ <strong>${result.skipped}</strong> bereits vorhanden (übersprungen)</li>`;
|
||||||
|
}
|
||||||
|
if (result.skippedNoCustomer > 0) {
|
||||||
|
html += `<li>⚠️ <strong>${result.skippedNoCustomer}</strong> übersprungen — Kunde nicht verknüpft:</li>`;
|
||||||
|
html += `<li class="ml-4 text-xs text-gray-500">${result.skippedCustomerNames.join(', ')}</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</ul></div>`;
|
||||||
|
resultDiv.innerHTML = html;
|
||||||
|
|
||||||
|
// Invoice-Liste aktualisieren
|
||||||
|
if (result.imported > 0) {
|
||||||
|
loadInvoices();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultDiv.innerHTML = `<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p class="font-semibold text-red-800">Import fehlgeschlagen</p>
|
||||||
|
<p class="text-sm text-red-600 mt-1">${result.error}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Import Error:', error);
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
resultDiv.innerHTML = `<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p class="text-red-600">Netzwerkfehler beim Import.</p>
|
||||||
|
</div>`;
|
||||||
|
} finally {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.openInvoiceModal = openInvoiceModal;
|
||||||
|
|
@ -67,13 +67,16 @@
|
||||||
|
|
||||||
<!-- Invoices Tab -->
|
<!-- Invoices Tab -->
|
||||||
<div id="invoices-tab" class="tab-content hidden">
|
<div id="invoices-tab" class="tab-content hidden">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-3xl font-bold text-gray-800">Invoices</h2>
|
<h2 class="text-3xl font-bold text-gray-800">Invoices</h2>
|
||||||
<button onclick="openInvoiceModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md">
|
<button onclick="openInvoiceModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-semibold shadow-md">
|
||||||
+ New Invoice
|
+ New Invoice
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar wird von invoice-view.js injiziert -->
|
||||||
|
<div id="invoice-toolbar"></div>
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
|
|
@ -146,6 +149,79 @@
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="upload-status" class="mt-4"></div>
|
<div id="upload-status" class="mt-4"></div>
|
||||||
|
|
||||||
|
<hr class="my-8 border-gray-200">
|
||||||
|
|
||||||
|
<h3 class="text-xl font-semibold mb-4 text-gray-800">QBO Rechnungs-Import</h3>
|
||||||
|
<p class="text-gray-600 mb-2">
|
||||||
|
Importiert alle <strong>unbezahlten</strong> Rechnungen aus QuickBooks Online in dein lokales System.
|
||||||
|
</p>
|
||||||
|
<ul class="text-sm text-gray-500 mb-4 list-disc list-inside">
|
||||||
|
<li>Bereits importierte Rechnungen werden übersprungen</li>
|
||||||
|
<li>Nur Kunden die lokal mit QBO verknüpft sind</li>
|
||||||
|
<li>Line Items (Labor/Parts) werden mit importiert</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button onclick="importFromQBO()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg font-semibold shadow-md flex items-center">
|
||||||
|
<span class="mr-2">📥</span> Unbezahlte Rechnungen importieren
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="qbo-import-result" class="mt-4 hidden"></div>
|
||||||
|
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Authorization</h3>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
Wenn der Token abgelaufen ist oder die Verbindung fehlschlägt,
|
||||||
|
hier neu autorisieren. Du wirst zu Intuit weitergeleitet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4 mb-4">
|
||||||
|
<a href="/auth/qbo"
|
||||||
|
class="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg font-semibold shadow-md inline-flex items-center">
|
||||||
|
🔑 Authorize QBO
|
||||||
|
</a>
|
||||||
|
<span id="qbo-status" class="text-sm text-gray-500">Checking...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// QBO Status beim Laden prüfen
|
||||||
|
fetch('/api/qbo/status')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const el = document.getElementById('qbo-status');
|
||||||
|
if (data.connected) {
|
||||||
|
el.innerHTML = '<span class="text-green-600">✅ Connected (Realm: ' + data.realmId + ')</span>';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = '<span class="text-red-600">❌ Not connected — please authorize</span>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
document.getElementById('qbo-status').innerHTML = '<span class="text-gray-400">Status unknown</span>';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Connection Test</h3>
|
||||||
|
<p class="text-gray-600 mb-4">Test the connection and token refresh logic by fetching a report of overdue invoices (> 30 days) directly from QBO.</p>
|
||||||
|
|
||||||
|
<button onclick="checkQboOverdue()" class="bg-purple-600 hover:bg-purple-700 text-white px-6 py-2 rounded-lg font-semibold shadow-md flex items-center">
|
||||||
|
<span id="qbo-btn-icon" class="mr-2">📡</span> Test Connection & Get Overdue Report
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="qbo-result" class="mt-6 hidden">
|
||||||
|
<h4 class="font-bold text-gray-700 mb-2">Results from QBO:</h4>
|
||||||
|
<div class="bg-gray-50 rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Inv #</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Customer</th>
|
||||||
|
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Due Date</th>
|
||||||
|
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="qbo-result-list" class="divide-y divide-gray-200 text-sm">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -172,21 +248,33 @@
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="space-y-3 pt-2">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Street Address</label>
|
<label class="block text-sm font-medium text-gray-700">Billing Address</label>
|
||||||
<input type="text" id="customer-street" required
|
|
||||||
|
<input type="text" id="customer-line1" placeholder="Line 1 (Street / PO Box / Company)"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
|
||||||
|
<input type="text" id="customer-line2" placeholder="Line 2"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<input type="text" id="customer-line3" placeholder="Line 3"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
|
||||||
|
<input type="text" id="customer-line4" placeholder="Line 4"
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4 pt-2">
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">City</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">City</label>
|
||||||
<input type="text" id="customer-city" required
|
<input type="text" id="customer-city"
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">State</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">State</label>
|
||||||
<input type="text" id="customer-state" required maxlength="2" placeholder="TX"
|
<input type="text" id="customer-state" maxlength="2" placeholder="TX"
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -194,7 +282,7 @@
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Zip Code</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Zip Code</label>
|
||||||
<input type="text" id="customer-zip" required
|
<input type="text" id="customer-zip"
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -204,6 +292,27 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input type="email" id="customer-email"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
|
||||||
|
<input type="tel" id="customer-phone"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input type="checkbox" id="customer-taxable"
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
|
<label for="customer-taxable" class="ml-2 block text-sm text-gray-900">Taxable</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4">
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
<button type="button" onclick="closeCustomerModal()"
|
<button type="button" onclick="closeCustomerModal()"
|
||||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
||||||
|
|
@ -445,5 +554,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
<script type="module" src="invoice-view-init.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
// invoice-view-init.js — Bootstrap-Script (type="module")
|
||||||
|
// Wird in index.html als <script type="module"> geladen.
|
||||||
|
// Importiert das Invoice-View Modul und verbindet es mit der bestehenden App.
|
||||||
|
|
||||||
|
import { loadInvoices, renderInvoiceView, injectToolbar } from './invoice-view.js';
|
||||||
|
|
||||||
|
// Warte bis DOM fertig ist
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
// Toolbar injizieren
|
||||||
|
injectToolbar();
|
||||||
|
|
||||||
|
// Globale Funktionen für app.js verfügbar machen
|
||||||
|
// (app.js ruft loadInvoices() auf wenn der Tab gewechselt wird)
|
||||||
|
window.loadInvoices = loadInvoices;
|
||||||
|
window.renderInvoices = renderInvoiceView;
|
||||||
|
|
||||||
|
// Initiales Laden
|
||||||
|
loadInvoices();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,503 @@
|
||||||
|
// invoice-view.js — ES Module für die Invoice View
|
||||||
|
// Features: Status Filter (all/unpaid/paid/overdue), Customer Filter,
|
||||||
|
// Group by (none/week/month), Sortierung neueste zuerst, Mark Paid/Unpaid
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// State
|
||||||
|
// ============================================================
|
||||||
|
let invoices = [];
|
||||||
|
let filterCustomer = '';
|
||||||
|
let filterStatus = 'unpaid'; // 'all' | 'unpaid' | 'paid' | 'overdue'
|
||||||
|
let groupBy = 'none'; // 'none' | 'week' | 'month'
|
||||||
|
|
||||||
|
const OVERDUE_DAYS = 30;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
const year = d.getFullYear();
|
||||||
|
return `${month}/${day}/${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysSince(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
const now = new Date();
|
||||||
|
return Math.floor((now - d) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekNumber(date) {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
|
||||||
|
const week1 = new Date(d.getFullYear(), 0, 4);
|
||||||
|
return {
|
||||||
|
year: d.getFullYear(),
|
||||||
|
week: 1 + Math.round(((d - week1) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekRange(year, weekNum) {
|
||||||
|
const jan4 = new Date(year, 0, 4);
|
||||||
|
const dayOfWeek = jan4.getDay() || 7;
|
||||||
|
const monday = new Date(jan4);
|
||||||
|
monday.setDate(jan4.getDate() - dayOfWeek + 1 + (weekNum - 1) * 7);
|
||||||
|
const sunday = new Date(monday);
|
||||||
|
sunday.setDate(monday.getDate() + 6);
|
||||||
|
return { start: formatDate(monday), end: formatDate(sunday) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonthName(monthIndex) {
|
||||||
|
return ['January','February','March','April','May','June',
|
||||||
|
'July','August','September','October','November','December'][monthIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPaid(inv) {
|
||||||
|
return !!inv.paid_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverdue(inv) {
|
||||||
|
return !isPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Data Loading
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function loadInvoices() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/invoices');
|
||||||
|
invoices = await response.json();
|
||||||
|
renderInvoiceView();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading invoices:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInvoicesData() {
|
||||||
|
return invoices;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Filtering & Sorting & Grouping
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function getFilteredInvoices() {
|
||||||
|
let filtered = [...invoices];
|
||||||
|
|
||||||
|
// Status Filter
|
||||||
|
if (filterStatus === 'unpaid') {
|
||||||
|
filtered = filtered.filter(inv => !isPaid(inv));
|
||||||
|
} else if (filterStatus === 'paid') {
|
||||||
|
filtered = filtered.filter(inv => isPaid(inv));
|
||||||
|
} else if (filterStatus === 'overdue') {
|
||||||
|
filtered = filtered.filter(inv => isOverdue(inv));
|
||||||
|
}
|
||||||
|
// 'all' → kein Filter
|
||||||
|
|
||||||
|
// Customer Filter
|
||||||
|
if (filterCustomer.trim()) {
|
||||||
|
const search = filterCustomer.toLowerCase();
|
||||||
|
filtered = filtered.filter(inv =>
|
||||||
|
(inv.customer_name || '').toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortierung: neueste zuerst
|
||||||
|
filtered.sort((a, b) => new Date(b.invoice_date) - new Date(a.invoice_date));
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupInvoices(filtered) {
|
||||||
|
if (groupBy === 'none') return null;
|
||||||
|
|
||||||
|
const groups = new Map();
|
||||||
|
|
||||||
|
filtered.forEach(inv => {
|
||||||
|
const d = new Date(inv.invoice_date);
|
||||||
|
let key, label;
|
||||||
|
|
||||||
|
if (groupBy === 'week') {
|
||||||
|
const wk = getWeekNumber(inv.invoice_date);
|
||||||
|
key = `${wk.year}-W${String(wk.week).padStart(2, '0')}`;
|
||||||
|
const range = getWeekRange(wk.year, wk.week);
|
||||||
|
label = `Week ${wk.week}, ${wk.year} (${range.start} – ${range.end})`;
|
||||||
|
} else if (groupBy === 'month') {
|
||||||
|
const month = d.getMonth();
|
||||||
|
const year = d.getFullYear();
|
||||||
|
key = `${year}-${String(month).padStart(2, '0')}`;
|
||||||
|
label = `${getMonthName(month)} ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, { label, invoices: [], total: 0 });
|
||||||
|
}
|
||||||
|
const group = groups.get(key);
|
||||||
|
group.invoices.push(inv);
|
||||||
|
group.total += parseFloat(inv.total) || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Innerhalb jeder Gruppe nochmal nach Datum sortieren (neueste zuerst)
|
||||||
|
for (const group of groups.values()) {
|
||||||
|
group.invoices.sort((a, b) => new Date(b.invoice_date) - new Date(a.invoice_date));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gruppen nach Key sortieren (neueste zuerst)
|
||||||
|
return new Map([...groups.entries()].sort((a, b) => b[0].localeCompare(a[0])));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Rendering
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function renderInvoiceRow(invoice) {
|
||||||
|
const hasQbo = !!invoice.qbo_id;
|
||||||
|
const paid = isPaid(invoice);
|
||||||
|
const overdue = isOverdue(invoice);
|
||||||
|
|
||||||
|
// QBO Button
|
||||||
|
const qboButton = hasQbo
|
||||||
|
? `<span class="text-gray-400 text-xs" title="Already in QBO (ID: ${invoice.qbo_id})">✓ QBO</span>`
|
||||||
|
: `<button onclick="window.invoiceView.exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">QBO Export</button>`;
|
||||||
|
|
||||||
|
// Paid/Unpaid Toggle Button
|
||||||
|
const paidButton = paid
|
||||||
|
? `<button onclick="window.invoiceView.markUnpaid(${invoice.id})" class="text-yellow-600 hover:text-yellow-800" title="Mark as unpaid">↩ Unpaid</button>`
|
||||||
|
: `<button onclick="window.invoiceView.markPaid(${invoice.id})" class="text-emerald-600 hover:text-emerald-800" title="Mark as paid">💰 Paid</button>`;
|
||||||
|
|
||||||
|
// Status Badge
|
||||||
|
let statusBadge = '';
|
||||||
|
if (paid) {
|
||||||
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="Paid ${formatDate(invoice.paid_date)}">Paid</span>`;
|
||||||
|
} else if (overdue) {
|
||||||
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800" title="${daysSince(invoice.invoice_date)} days old">Overdue</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row styling
|
||||||
|
const rowClass = paid ? 'bg-green-50/50' : overdue ? 'bg-red-50/50' : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr class="${rowClass}">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
${invoice.invoice_number} ${statusBadge}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">$${parseFloat(invoice.total).toFixed(2)}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||||
|
<button onclick="window.invoiceView.viewPDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>
|
||||||
|
<button onclick="window.invoiceView.viewHTML(${invoice.id})" class="text-teal-600 hover:text-teal-900">HTML</button>
|
||||||
|
${qboButton}
|
||||||
|
${paidButton}
|
||||||
|
<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||||||
|
<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupHeader(label) {
|
||||||
|
return `
|
||||||
|
<tr class="bg-blue-50">
|
||||||
|
<td colspan="6" class="px-6 py-3 text-sm font-bold text-blue-800">
|
||||||
|
📅 ${label}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupFooter(total, count) {
|
||||||
|
return `
|
||||||
|
<tr class="bg-gray-50 border-t-2 border-gray-300">
|
||||||
|
<td colspan="4" class="px-6 py-3 text-sm font-bold text-gray-700 text-right">Group Total (${count} invoices):</td>
|
||||||
|
<td class="px-6 py-3 text-sm font-bold text-gray-900">$${total.toFixed(2)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderInvoiceView() {
|
||||||
|
const tbody = document.getElementById('invoices-list');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
const filtered = getFilteredInvoices();
|
||||||
|
const groups = groupInvoices(filtered);
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
let grandTotal = 0;
|
||||||
|
|
||||||
|
if (groups) {
|
||||||
|
for (const [key, group] of groups) {
|
||||||
|
html += renderGroupHeader(group.label);
|
||||||
|
group.invoices.forEach(inv => {
|
||||||
|
html += renderInvoiceRow(inv);
|
||||||
|
});
|
||||||
|
html += renderGroupFooter(group.total, group.invoices.length);
|
||||||
|
grandTotal += group.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups.size > 1) {
|
||||||
|
html += `
|
||||||
|
<tr class="bg-blue-100 border-t-4 border-blue-400">
|
||||||
|
<td colspan="4" class="px-6 py-4 text-base font-bold text-blue-900 text-right">Grand Total (${filtered.length} invoices):</td>
|
||||||
|
<td class="px-6 py-4 text-base font-bold text-blue-900">$${grandTotal.toFixed(2)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filtered.forEach(inv => {
|
||||||
|
html += renderInvoiceRow(inv);
|
||||||
|
grandTotal += parseFloat(inv.total) || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
html += `
|
||||||
|
<tr class="bg-gray-100 border-t-2 border-gray-300">
|
||||||
|
<td colspan="4" class="px-6 py-3 text-sm font-bold text-gray-700 text-right">Total (${filtered.length} invoices):</td>
|
||||||
|
<td class="px-6 py-3 text-sm font-bold text-gray-900">$${grandTotal.toFixed(2)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
html = `<tr><td colspan="6" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = html;
|
||||||
|
|
||||||
|
// Update count badge
|
||||||
|
const countEl = document.getElementById('invoice-count');
|
||||||
|
if (countEl) countEl.textContent = filtered.length;
|
||||||
|
|
||||||
|
// Update status button active states
|
||||||
|
updateStatusButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatusButtons() {
|
||||||
|
document.querySelectorAll('[data-status-filter]').forEach(btn => {
|
||||||
|
const status = btn.getAttribute('data-status-filter');
|
||||||
|
if (status === filterStatus) {
|
||||||
|
btn.classList.remove('bg-white', 'text-gray-600');
|
||||||
|
btn.classList.add('bg-blue-600', 'text-white');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('bg-blue-600', 'text-white');
|
||||||
|
btn.classList.add('bg-white', 'text-gray-600');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update overdue count badge
|
||||||
|
const overdueCount = invoices.filter(inv => isOverdue(inv)).length;
|
||||||
|
const overdueBadge = document.getElementById('overdue-badge');
|
||||||
|
if (overdueBadge) {
|
||||||
|
if (overdueCount > 0) {
|
||||||
|
overdueBadge.textContent = overdueCount;
|
||||||
|
overdueBadge.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
overdueBadge.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update unpaid count
|
||||||
|
const unpaidCount = invoices.filter(inv => !isPaid(inv)).length;
|
||||||
|
const unpaidBadge = document.getElementById('unpaid-badge');
|
||||||
|
if (unpaidBadge) {
|
||||||
|
unpaidBadge.textContent = unpaidCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Toolbar HTML
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function injectToolbar() {
|
||||||
|
const container = document.getElementById('invoice-toolbar');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mb-4 p-4 bg-white rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<!-- Status Filter Buttons -->
|
||||||
|
<div class="flex items-center gap-1 border border-gray-300 rounded-lg p-1 bg-gray-100">
|
||||||
|
<button data-status-filter="all"
|
||||||
|
onclick="window.invoiceView.setStatus('all')"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button data-status-filter="unpaid"
|
||||||
|
onclick="window.invoiceView.setStatus('unpaid')"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-blue-600 text-white">
|
||||||
|
Unpaid <span id="unpaid-badge" class="ml-1 text-xs opacity-80"></span>
|
||||||
|
</button>
|
||||||
|
<button data-status-filter="paid"
|
||||||
|
onclick="window.invoiceView.setStatus('paid')"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
|
||||||
|
Paid
|
||||||
|
</button>
|
||||||
|
<button data-status-filter="overdue"
|
||||||
|
onclick="window.invoiceView.setStatus('overdue')"
|
||||||
|
class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600">
|
||||||
|
Overdue
|
||||||
|
<span id="overdue-badge" class="hidden absolute -top-1.5 -right-1.5 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-px h-8 bg-gray-300"></div>
|
||||||
|
|
||||||
|
<!-- Customer Filter -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700">Customer:</label>
|
||||||
|
<input type="text" id="invoice-filter-customer" placeholder="Filter by name..."
|
||||||
|
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm w-48 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-px h-8 bg-gray-300"></div>
|
||||||
|
|
||||||
|
<!-- Group By -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700">Group:</label>
|
||||||
|
<select id="invoice-group-by" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm bg-white focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="week">Week</option>
|
||||||
|
<option value="month">Month</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Invoice Count -->
|
||||||
|
<div class="ml-auto text-sm text-gray-500">
|
||||||
|
<span id="invoice-count" class="font-semibold text-gray-700">0</span> invoices
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Event Listeners
|
||||||
|
document.getElementById('invoice-filter-customer').addEventListener('input', (e) => {
|
||||||
|
filterCustomer = e.target.value;
|
||||||
|
renderInvoiceView();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('invoice-group-by').addEventListener('change', (e) => {
|
||||||
|
groupBy = e.target.value;
|
||||||
|
renderInvoiceView();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Actions
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function setStatus(status) {
|
||||||
|
filterStatus = status;
|
||||||
|
renderInvoiceView();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewPDF(id) {
|
||||||
|
window.open(`/api/invoices/${id}/pdf`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewHTML(id) {
|
||||||
|
window.open(`/api/invoices/${id}/html`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportToQBO(id) {
|
||||||
|
if (!confirm('Rechnung wirklich an QuickBooks Online senden?')) return;
|
||||||
|
|
||||||
|
const btn = event.target;
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = "⏳...";
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/invoices/${id}/export`, { method: 'POST' });
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert(`✅ Erfolg! QBO ID: ${result.qbo_id}, Rechnungsnr: ${result.qbo_doc_number}`);
|
||||||
|
loadInvoices();
|
||||||
|
} else {
|
||||||
|
alert(`❌ Fehler: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('Netzwerkfehler beim Export.');
|
||||||
|
} finally {
|
||||||
|
btn.textContent = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markPaid(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/invoices/${id}/mark-paid`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ paid_date: new Date().toISOString().split('T')[0] })
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
loadInvoices();
|
||||||
|
} else {
|
||||||
|
const err = await response.json();
|
||||||
|
alert('Error: ' + (err.error || 'Unknown'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking paid:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markUnpaid(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/invoices/${id}/mark-unpaid`, { method: 'PATCH' });
|
||||||
|
if (response.ok) {
|
||||||
|
loadInvoices();
|
||||||
|
} else {
|
||||||
|
const err = await response.json();
|
||||||
|
alert('Error: ' + (err.error || 'Unknown'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking unpaid:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function edit(id) {
|
||||||
|
if (typeof window.openInvoiceModal === 'function') {
|
||||||
|
await window.openInvoiceModal(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(id) {
|
||||||
|
if (!confirm('Are you sure you want to delete this invoice?')) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/invoices/${id}`, { method: 'DELETE' });
|
||||||
|
if (response.ok) {
|
||||||
|
loadInvoices();
|
||||||
|
} else {
|
||||||
|
alert('Error deleting invoice');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Expose to window
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
window.invoiceView = {
|
||||||
|
viewPDF,
|
||||||
|
viewHTML,
|
||||||
|
exportToQBO,
|
||||||
|
markPaid,
|
||||||
|
markUnpaid,
|
||||||
|
edit,
|
||||||
|
remove,
|
||||||
|
loadInvoices,
|
||||||
|
renderInvoiceView,
|
||||||
|
setStatus
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
// qbo_helper.js - DEFINITIVER FIX
|
||||||
|
//
|
||||||
|
// Kernproblem: client.refresh() ruft intern validateToken() auf,
|
||||||
|
// das das Token-Objekt prüft und "invalid" wirft wenn das Format
|
||||||
|
// nicht stimmt. Das passiert LOKAL, nicht bei Intuit.
|
||||||
|
//
|
||||||
|
// Lösung: refreshUsingToken(refreshTokenString) verwenden.
|
||||||
|
// Diese Methode akzeptiert den RT direkt als String und umgeht
|
||||||
|
// die validateToken()-Prüfung komplett.
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
const OAuthClient = require('intuit-oauth');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
let oauthClient = null;
|
||||||
|
const tokenFile = path.join(__dirname, 'qbo_token.json');
|
||||||
|
|
||||||
|
const getOAuthClient = () => {
|
||||||
|
if (!oauthClient) {
|
||||||
|
oauthClient = new OAuthClient({
|
||||||
|
clientId: process.env.QBO_CLIENT_ID,
|
||||||
|
clientSecret: process.env.QBO_CLIENT_SECRET,
|
||||||
|
environment: process.env.QBO_ENVIRONMENT || 'sandbox',
|
||||||
|
redirectUri: process.env.QBO_REDIRECT_URI
|
||||||
|
});
|
||||||
|
|
||||||
|
let savedToken = null;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tokenFile)) {
|
||||||
|
const stat = fs.statSync(tokenFile);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
const content = fs.readFileSync(tokenFile, 'utf8');
|
||||||
|
if (content.trim() !== "{}") {
|
||||||
|
savedToken = JSON.parse(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("❌ Fehler beim Laden des gespeicherten Tokens:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedToken && savedToken.refresh_token) {
|
||||||
|
oauthClient.setToken(savedToken);
|
||||||
|
console.log("✅ Gespeicherter Token aus qbo_token.json geladen.");
|
||||||
|
} else {
|
||||||
|
const envToken = {
|
||||||
|
token_type: 'bearer',
|
||||||
|
access_token: process.env.QBO_ACCESS_TOKEN || '',
|
||||||
|
refresh_token: process.env.QBO_REFRESH_TOKEN || '',
|
||||||
|
expires_in: 3600,
|
||||||
|
x_refresh_token_expires_in: 8726400,
|
||||||
|
realmId: process.env.QBO_REALM_ID,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
if (envToken.refresh_token) {
|
||||||
|
oauthClient.setToken(envToken);
|
||||||
|
console.log("ℹ️ Token aus .env geladen (Fallback).");
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ Kein gültiger Token vorhanden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return oauthClient;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resetOAuthClient() {
|
||||||
|
oauthClient = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTokens() {
|
||||||
|
try {
|
||||||
|
const client = getOAuthClient();
|
||||||
|
const token = client.getToken();
|
||||||
|
|
||||||
|
// Debug: Was genau bekommen wir vom Client?
|
||||||
|
console.log("💾 Speichere Token... refresh_token vorhanden:", !!token.refresh_token,
|
||||||
|
"| access_token Länge:", (token.access_token || '').length,
|
||||||
|
"| realmId:", token.realmId || 'FEHLT');
|
||||||
|
|
||||||
|
// Sicherstellen dass alle Pflichtfelder vorhanden sind
|
||||||
|
const tokenToSave = {
|
||||||
|
token_type: token.token_type || 'bearer',
|
||||||
|
access_token: token.access_token,
|
||||||
|
refresh_token: token.refresh_token,
|
||||||
|
expires_in: token.expires_in || 3600,
|
||||||
|
x_refresh_token_expires_in: token.x_refresh_token_expires_in || 8726400,
|
||||||
|
realmId: token.realmId || process.env.QBO_REALM_ID,
|
||||||
|
createdAt: token.createdAt || new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(tokenFile, JSON.stringify(tokenToSave, null, 2));
|
||||||
|
console.log("💾 Tokens erfolgreich in qbo_token.json gespeichert.");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("❌ Fehler beim Speichern der Tokens:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeQboApiCall(requestOptions) {
|
||||||
|
const client = getOAuthClient();
|
||||||
|
|
||||||
|
const currentToken = client.getToken();
|
||||||
|
if (!currentToken || !currentToken.refresh_token) {
|
||||||
|
throw new Error("Kein gültiger QBO Token vorhanden. Bitte Token erneuern.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const doRefresh = async () => {
|
||||||
|
console.log("🔄 QBO Token Refresh wird ausgeführt...");
|
||||||
|
|
||||||
|
// Den Refresh Token als String extrahieren
|
||||||
|
const refreshTokenStr = currentToken.refresh_token;
|
||||||
|
console.log("🔑 Refresh Token (erste 15 Zeichen):", refreshTokenStr.substring(0, 15) + "...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// KRITISCHER FIX: refreshUsingToken() statt refresh() verwenden!
|
||||||
|
//
|
||||||
|
// refresh() ruft intern validateToken() auf, das bei unvollständigem
|
||||||
|
// Token-Objekt "The Refresh token is invalid" wirft — OHNE jemals
|
||||||
|
// Intuit zu kontaktieren.
|
||||||
|
//
|
||||||
|
// refreshUsingToken() akzeptiert den RT als String und umgeht das.
|
||||||
|
const authResponse = await client.refreshUsingToken(refreshTokenStr);
|
||||||
|
console.log("✅ Token erfolgreich erneuert via refreshUsingToken().");
|
||||||
|
saveTokens();
|
||||||
|
return authResponse;
|
||||||
|
} catch (e) {
|
||||||
|
const errMsg = e.originalMessage || e.message || String(e);
|
||||||
|
console.error("❌ Refresh fehlgeschlagen:", errMsg);
|
||||||
|
if (e.intuit_tid) console.error(" intuit_tid:", e.intuit_tid);
|
||||||
|
|
||||||
|
if (errMsg.includes('invalid_grant')) {
|
||||||
|
throw new Error(
|
||||||
|
"Der Refresh Token ist bei Intuit ungültig (invalid_grant). " +
|
||||||
|
"Bitte im Playground einen neuen Token holen und set_qbo_token.js ausführen."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.makeApiCall(requestOptions);
|
||||||
|
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
|
||||||
|
if (data.fault && data.fault.error) {
|
||||||
|
const errorCode = data.fault.error[0].code;
|
||||||
|
|
||||||
|
if (errorCode === '3200' || errorCode === '3202' || errorCode === '3100') {
|
||||||
|
console.log(`⚠️ QBO meldet Token-Fehler (${errorCode}). Versuche Refresh und Retry...`);
|
||||||
|
await doRefresh();
|
||||||
|
return await client.makeApiCall(requestOptions);
|
||||||
|
}
|
||||||
|
throw new Error(`QBO API Error ${errorCode}: ${data.fault.error[0].message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTokens();
|
||||||
|
return response;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
const isAuthError =
|
||||||
|
e.response?.status === 401 ||
|
||||||
|
(e.authResponse && e.authResponse.response && e.authResponse.response.status === 401) ||
|
||||||
|
e.message?.includes('AuthenticationFailed');
|
||||||
|
|
||||||
|
if (isAuthError) {
|
||||||
|
console.log("⚠️ 401 Unauthorized / AuthFailed erhalten. Versuche Refresh und Retry...");
|
||||||
|
await doRefresh();
|
||||||
|
return await client.makeApiCall(requestOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOAuthClient,
|
||||||
|
makeQboApiCall,
|
||||||
|
saveTokens,
|
||||||
|
resetOAuthClient
|
||||||
|
};
|
||||||
689
server.js
689
server.js
|
|
@ -4,6 +4,8 @@ const path = require('path');
|
||||||
const puppeteer = require('puppeteer');
|
const puppeteer = require('puppeteer');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
|
const OAuthClient = require('intuit-oauth');
|
||||||
|
const { makeQboApiCall, getOAuthClient, saveTokens, resetOAuthClient } = require('./qbo_helper');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
@ -161,12 +163,25 @@ app.get('/api/customers', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/customers
|
||||||
app.post('/api/customers', async (req, res) => {
|
app.post('/api/customers', async (req, res) => {
|
||||||
const { name, street, city, state, zip_code, account_number } = req.body;
|
// line1 bis line4 statt street/pobox/suite
|
||||||
|
const {
|
||||||
|
name, line1, line2, line3, line4, city, state, zip_code,
|
||||||
|
account_number, email, phone, phone2, taxable
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'INSERT INTO customers (name, street, city, state, zip_code, account_number) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *',
|
`INSERT INTO customers
|
||||||
[name, street, city, state, zip_code, account_number]
|
(name, line1, line2, line3, line4, city, state,
|
||||||
|
zip_code, account_number, email, phone, phone2, taxable)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING *`,
|
||||||
|
[name, line1 || null, line2 || null, line3 || null, line4 || null,
|
||||||
|
city || null, state || null, zip_code || null,
|
||||||
|
account_number || null, email || null, phone || null, phone2 || null,
|
||||||
|
taxable !== undefined ? taxable : true]
|
||||||
);
|
);
|
||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -175,13 +190,26 @@ app.post('/api/customers', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PUT /api/customers/:id
|
||||||
app.put('/api/customers/:id', async (req, res) => {
|
app.put('/api/customers/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { name, street, city, state, zip_code, account_number } = req.body;
|
const {
|
||||||
|
name, line1, line2, line3, line4, city, state, zip_code,
|
||||||
|
account_number, email, phone, phone2, taxable
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'UPDATE customers SET name = $1, street = $2, city = $3, state = $4, zip_code = $5, account_number = $6, updated_at = CURRENT_TIMESTAMP WHERE id = $7 RETURNING *',
|
`UPDATE customers
|
||||||
[name, street, city, state, zip_code, account_number, id]
|
SET name = $1, line1 = $2, line2 = $3, line3 = $4, line4 = $5,
|
||||||
|
city = $6, state = $7, zip_code = $8, account_number = $9, email = $10,
|
||||||
|
phone = $11, phone2 = $12, taxable = $13, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $14
|
||||||
|
RETURNING *`,
|
||||||
|
[name, line1 || null, line2 || null, line3 || null, line4 || null,
|
||||||
|
city || null, state || null, zip_code || null,
|
||||||
|
account_number || null, email || null, phone || null, phone2 || null,
|
||||||
|
taxable !== undefined ? taxable : true, id]
|
||||||
);
|
);
|
||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -220,8 +248,9 @@ app.get('/api/quotes', async (req, res) => {
|
||||||
app.get('/api/quotes/:id', async (req, res) => {
|
app.get('/api/quotes/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
try {
|
try {
|
||||||
|
// KORRIGIERT: c.line1...c.line4 statt c.street
|
||||||
const quoteResult = await pool.query(`
|
const quoteResult = await pool.query(`
|
||||||
SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
|
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||||
FROM quotes q
|
FROM quotes q
|
||||||
LEFT JOIN customers c ON q.customer_id = c.id
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
WHERE q.id = $1
|
WHERE q.id = $1
|
||||||
|
|
@ -283,8 +312,8 @@ app.post('/api/quotes', async (req, res) => {
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)',
|
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||||
[quoteId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i]
|
[quoteId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -336,8 +365,8 @@ app.put('/api/quotes/:id', async (req, res) => {
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)',
|
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||||
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i]
|
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -400,8 +429,9 @@ app.get('/api/invoices/next-number', async (req, res) => {
|
||||||
app.get('/api/invoices/:id', async (req, res) => {
|
app.get('/api/invoices/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
try {
|
try {
|
||||||
|
// KORRIGIERT: c.line1, c.line2, c.line3, c.line4 statt c.street
|
||||||
const invoiceResult = await pool.query(`
|
const invoiceResult = await pool.query(`
|
||||||
SELECT i.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
|
SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||||
FROM invoices i
|
FROM invoices i
|
||||||
LEFT JOIN customers c ON i.customer_id = c.id
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
WHERE i.id = $1
|
WHERE i.id = $1
|
||||||
|
|
@ -473,8 +503,9 @@ app.post('/api/invoices', async (req, res) => {
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)',
|
// qbo_item_id hinzugefügt
|
||||||
[invoiceId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i]
|
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||||
|
[invoiceId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9'] // Default '9' (Parts)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -496,8 +527,9 @@ app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// KORRIGIERT: c.line1...c.line4 statt c.street
|
||||||
const quoteResult = await pool.query(`
|
const quoteResult = await pool.query(`
|
||||||
SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
|
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||||
FROM quotes q
|
FROM quotes q
|
||||||
LEFT JOIN customers c ON q.customer_id = c.id
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
WHERE q.id = $1
|
WHERE q.id = $1
|
||||||
|
|
@ -538,8 +570,8 @@ app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => {
|
||||||
for (let i = 0; i < itemsResult.rows.length; i++) {
|
for (let i = 0; i < itemsResult.rows.length; i++) {
|
||||||
const item = itemsResult.rows[i];
|
const item = itemsResult.rows[i];
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)',
|
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||||
[invoiceId, item.quantity, item.description, item.rate, item.amount, i]
|
[invoiceId, item.quantity, item.description, item.rate, item.amount, i, item.qbo_item_id || '9']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -603,8 +635,8 @@ app.put('/api/invoices/:id', async (req, res) => {
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order) VALUES ($1, $2, $3, $4, $5, $6)',
|
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||||
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i]
|
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -646,8 +678,9 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
|
||||||
console.log(`[PDF] Starting quote PDF generation for ID: ${id}`);
|
console.log(`[PDF] Starting quote PDF generation for ID: ${id}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// KORRIGIERT: Abfrage von line1-4 statt street/pobox
|
||||||
const quoteResult = await pool.query(`
|
const quoteResult = await pool.query(`
|
||||||
SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
|
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||||
FROM quotes q
|
FROM quotes q
|
||||||
LEFT JOIN customers c ON q.customer_id = c.id
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
WHERE q.id = $1
|
WHERE q.id = $1
|
||||||
|
|
@ -663,29 +696,23 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load template and replace placeholders
|
|
||||||
const templatePath = path.join(__dirname, 'templates', 'quote-template.html');
|
const templatePath = path.join(__dirname, 'templates', 'quote-template.html');
|
||||||
let html = await fs.readFile(templatePath, 'utf-8');
|
let html = await fs.readFile(templatePath, 'utf-8');
|
||||||
|
|
||||||
// Get logo
|
|
||||||
let logoHTML = '';
|
let logoHTML = '';
|
||||||
try {
|
try {
|
||||||
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
|
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
|
||||||
const logoData = await fs.readFile(logoPath);
|
const logoData = await fs.readFile(logoPath);
|
||||||
const logoBase64 = logoData.toString('base64');
|
const logoBase64 = logoData.toString('base64');
|
||||||
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
|
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
|
||||||
} catch (err) {
|
} catch (err) {}
|
||||||
// No logo
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate items HTML
|
// Items HTML generieren
|
||||||
let itemsHTML = itemsResult.rows.map(item => {
|
let itemsHTML = itemsResult.rows.map(item => {
|
||||||
let rateFormatted = item.rate;
|
let rateFormatted = item.rate;
|
||||||
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
||||||
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
||||||
if (!isNaN(rateNum)) {
|
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
||||||
rateFormatted = rateNum.toFixed(2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -693,17 +720,15 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
|
||||||
<td class="description">${item.description}</td>
|
<td class="description">${item.description}</td>
|
||||||
<td class="rate">${rateFormatted}</td>
|
<td class="rate">${rateFormatted}</td>
|
||||||
<td class="amount">${item.amount}</td>
|
<td class="amount">${item.amount}</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
`;
|
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Add totals
|
// Totals
|
||||||
itemsHTML += `
|
itemsHTML += `
|
||||||
<tr class="footer-row">
|
<tr class="footer-row">
|
||||||
<td colspan="3" class="total-label">Subtotal:</td>
|
<td colspan="3" class="total-label">Subtotal:</td>
|
||||||
<td class="total-amount">$${parseFloat(quote.subtotal).toFixed(2)}</td>
|
<td class="total-amount">$${parseFloat(quote.subtotal).toFixed(2)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
if (!quote.tax_exempt) {
|
if (!quote.tax_exempt) {
|
||||||
itemsHTML += `
|
itemsHTML += `
|
||||||
<tr class="footer-row">
|
<tr class="footer-row">
|
||||||
|
|
@ -711,7 +736,6 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
|
||||||
<td class="total-amount">$${parseFloat(quote.tax_amount).toFixed(2)}</td>
|
<td class="total-amount">$${parseFloat(quote.tax_amount).toFixed(2)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
itemsHTML += `
|
itemsHTML += `
|
||||||
<tr class="footer-row">
|
<tr class="footer-row">
|
||||||
<td colspan="3" class="total-label">TOTAL:</td>
|
<td colspan="3" class="total-label">TOTAL:</td>
|
||||||
|
|
@ -721,16 +745,25 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
|
||||||
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
let tbdNote = '';
|
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
|
||||||
if (quote.has_tbd) {
|
|
||||||
tbdNote = '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD" (To Be Determined). The final total may vary once all details are finalized.</em></p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace placeholders
|
// --- ADRESS-LOGIK (NEU) ---
|
||||||
|
const addressLines = [];
|
||||||
|
// Wenn line1 existiert UND ungleich dem Namen ist, hinzufügen. Sonst überspringen (da Name eh drüber steht).
|
||||||
|
if (quote.line1 && quote.line1.trim().toLowerCase() !== (quote.customer_name || '').trim().toLowerCase()) {
|
||||||
|
addressLines.push(quote.line1);
|
||||||
|
}
|
||||||
|
if (quote.line2) addressLines.push(quote.line2);
|
||||||
|
if (quote.line3) addressLines.push(quote.line3);
|
||||||
|
if (quote.line4) addressLines.push(quote.line4);
|
||||||
|
|
||||||
|
const streetBlock = addressLines.join('<br>');
|
||||||
|
|
||||||
|
// Ersetzen
|
||||||
html = html
|
html = html
|
||||||
.replace('{{LOGO_HTML}}', logoHTML)
|
.replace('{{LOGO_HTML}}', logoHTML)
|
||||||
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
||||||
.replace('{{CUSTOMER_STREET}}', quote.street || '')
|
.replace('{{CUSTOMER_STREET}}', streetBlock) // Hier kommt der Block rein
|
||||||
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
||||||
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
||||||
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
||||||
|
|
@ -740,20 +773,14 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
|
||||||
.replace('{{ITEMS}}', itemsHTML)
|
.replace('{{ITEMS}}', itemsHTML)
|
||||||
.replace('{{TBD_NOTE}}', tbdNote);
|
.replace('{{TBD_NOTE}}', tbdNote);
|
||||||
|
|
||||||
// Use persistent browser
|
|
||||||
const browserInstance = await initBrowser();
|
const browserInstance = await initBrowser();
|
||||||
const page = await browserInstance.newPage();
|
const page = await browserInstance.newPage();
|
||||||
|
|
||||||
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
|
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||||
|
|
||||||
const pdf = await page.pdf({
|
const pdf = await page.pdf({
|
||||||
format: 'Letter',
|
format: 'Letter', printBackground: true,
|
||||||
printBackground: true,
|
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' }
|
||||||
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
|
|
||||||
timeout: 60000
|
|
||||||
});
|
});
|
||||||
|
await page.close();
|
||||||
await page.close(); // Close page, not browser
|
|
||||||
|
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Type': 'application/pdf',
|
'Content-Type': 'application/pdf',
|
||||||
|
|
@ -771,12 +798,12 @@ app.get('/api/quotes/:id/pdf', async (req, res) => {
|
||||||
|
|
||||||
app.get('/api/invoices/:id/pdf', async (req, res) => {
|
app.get('/api/invoices/:id/pdf', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
console.log(`[INVOICE-PDF] Starting invoice PDF generation for ID: ${id}`);
|
console.log(`[INVOICE-PDF] Starting invoice PDF generation for ID: ${id}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// KORRIGIERT: Abfrage von line1-4
|
||||||
const invoiceResult = await pool.query(`
|
const invoiceResult = await pool.query(`
|
||||||
SELECT i.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
|
SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||||
FROM invoices i
|
FROM invoices i
|
||||||
LEFT JOIN customers c ON i.customer_id = c.id
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
WHERE i.id = $1
|
WHERE i.id = $1
|
||||||
|
|
@ -792,29 +819,22 @@ app.get('/api/invoices/:id/pdf', async (req, res) => {
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load template
|
|
||||||
const templatePath = path.join(__dirname, 'templates', 'invoice-template.html');
|
const templatePath = path.join(__dirname, 'templates', 'invoice-template.html');
|
||||||
let html = await fs.readFile(templatePath, 'utf-8');
|
let html = await fs.readFile(templatePath, 'utf-8');
|
||||||
|
|
||||||
// Get logo
|
|
||||||
let logoHTML = '';
|
let logoHTML = '';
|
||||||
try {
|
try {
|
||||||
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
|
const logoPath = path.join(__dirname, 'public', 'uploads', 'company-logo.png');
|
||||||
const logoData = await fs.readFile(logoPath);
|
const logoData = await fs.readFile(logoPath);
|
||||||
const logoBase64 = logoData.toString('base64');
|
const logoBase64 = logoData.toString('base64');
|
||||||
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
|
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
|
||||||
} catch (err) {
|
} catch (err) {}
|
||||||
// No logo
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate items HTML
|
|
||||||
let itemsHTML = itemsResult.rows.map(item => {
|
let itemsHTML = itemsResult.rows.map(item => {
|
||||||
let rateFormatted = item.rate;
|
let rateFormatted = item.rate;
|
||||||
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
||||||
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
||||||
if (!isNaN(rateNum)) {
|
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
||||||
rateFormatted = rateNum.toFixed(2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -822,17 +842,14 @@ app.get('/api/invoices/:id/pdf', async (req, res) => {
|
||||||
<td class="description">${item.description}</td>
|
<td class="description">${item.description}</td>
|
||||||
<td class="rate">${rateFormatted}</td>
|
<td class="rate">${rateFormatted}</td>
|
||||||
<td class="amount">${item.amount}</td>
|
<td class="amount">${item.amount}</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
`;
|
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Add totals
|
|
||||||
itemsHTML += `
|
itemsHTML += `
|
||||||
<tr class="footer-row">
|
<tr class="footer-row">
|
||||||
<td colspan="3" class="total-label">Subtotal:</td>
|
<td colspan="3" class="total-label">Subtotal:</td>
|
||||||
<td class="total-amount">$${parseFloat(invoice.subtotal).toFixed(2)}</td>
|
<td class="total-amount">$${parseFloat(invoice.subtotal).toFixed(2)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
if (!invoice.tax_exempt) {
|
if (!invoice.tax_exempt) {
|
||||||
itemsHTML += `
|
itemsHTML += `
|
||||||
<tr class="footer-row">
|
<tr class="footer-row">
|
||||||
|
|
@ -840,7 +857,6 @@ app.get('/api/invoices/:id/pdf', async (req, res) => {
|
||||||
<td class="total-amount">$${parseFloat(invoice.tax_amount).toFixed(2)}</td>
|
<td class="total-amount">$${parseFloat(invoice.tax_amount).toFixed(2)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
itemsHTML += `
|
itemsHTML += `
|
||||||
<tr class="footer-row">
|
<tr class="footer-row">
|
||||||
<td colspan="3" class="total-label">TOTAL:</td>
|
<td colspan="3" class="total-label">TOTAL:</td>
|
||||||
|
|
@ -850,15 +866,23 @@ app.get('/api/invoices/:id/pdf', async (req, res) => {
|
||||||
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
// Authorization field
|
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
|
||||||
const authHTML = invoice.auth_code ?
|
|
||||||
`<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
|
// --- ADRESS-LOGIK (NEU) ---
|
||||||
|
const addressLines = [];
|
||||||
|
if (invoice.line1 && invoice.line1.trim().toLowerCase() !== (invoice.customer_name || '').trim().toLowerCase()) {
|
||||||
|
addressLines.push(invoice.line1);
|
||||||
|
}
|
||||||
|
if (invoice.line2) addressLines.push(invoice.line2);
|
||||||
|
if (invoice.line3) addressLines.push(invoice.line3);
|
||||||
|
if (invoice.line4) addressLines.push(invoice.line4);
|
||||||
|
|
||||||
|
const streetBlock = addressLines.join('<br>');
|
||||||
|
|
||||||
// Replace placeholders
|
|
||||||
html = html
|
html = html
|
||||||
.replace('{{LOGO_HTML}}', logoHTML)
|
.replace('{{LOGO_HTML}}', logoHTML)
|
||||||
.replace('{{CUSTOMER_NAME}}', invoice.customer_name || '')
|
.replace('{{CUSTOMER_NAME}}', invoice.customer_name || '')
|
||||||
.replace('{{CUSTOMER_STREET}}', invoice.street || '')
|
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
||||||
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
|
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
|
||||||
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
|
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
|
||||||
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
|
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
|
||||||
|
|
@ -869,20 +893,14 @@ app.get('/api/invoices/:id/pdf', async (req, res) => {
|
||||||
.replace('{{AUTHORIZATION}}', authHTML)
|
.replace('{{AUTHORIZATION}}', authHTML)
|
||||||
.replace('{{ITEMS}}', itemsHTML);
|
.replace('{{ITEMS}}', itemsHTML);
|
||||||
|
|
||||||
// Use persistent browser
|
|
||||||
const browserInstance = await initBrowser();
|
const browserInstance = await initBrowser();
|
||||||
const page = await browserInstance.newPage();
|
const page = await browserInstance.newPage();
|
||||||
|
|
||||||
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
|
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||||
|
|
||||||
const pdf = await page.pdf({
|
const pdf = await page.pdf({
|
||||||
format: 'Letter',
|
format: 'Letter', printBackground: true,
|
||||||
printBackground: true,
|
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' }
|
||||||
margin: { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
|
|
||||||
timeout: 60000
|
|
||||||
});
|
});
|
||||||
|
await page.close();
|
||||||
await page.close(); // Close page, not browser
|
|
||||||
|
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Type': 'application/pdf',
|
'Content-Type': 'application/pdf',
|
||||||
|
|
@ -904,8 +922,9 @@ app.get('/api/quotes/:id/html', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// KORREKTUR: Line 1-4 abfragen
|
||||||
const quoteResult = await pool.query(`
|
const quoteResult = await pool.query(`
|
||||||
SELECT q.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
|
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||||
FROM quotes q
|
FROM quotes q
|
||||||
LEFT JOIN customers c ON q.customer_id = c.id
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
WHERE q.id = $1
|
WHERE q.id = $1
|
||||||
|
|
@ -936,9 +955,7 @@ app.get('/api/quotes/:id/html', async (req, res) => {
|
||||||
let rateFormatted = item.rate;
|
let rateFormatted = item.rate;
|
||||||
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
||||||
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
||||||
if (!isNaN(rateNum)) {
|
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
||||||
rateFormatted = rateNum.toFixed(2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -946,8 +963,7 @@ app.get('/api/quotes/:id/html', async (req, res) => {
|
||||||
<td class="description">${item.description}</td>
|
<td class="description">${item.description}</td>
|
||||||
<td class="rate">${rateFormatted}</td>
|
<td class="rate">${rateFormatted}</td>
|
||||||
<td class="amount">${item.amount}</td>
|
<td class="amount">${item.amount}</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
`;
|
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
itemsHTML += `
|
itemsHTML += `
|
||||||
|
|
@ -955,7 +971,6 @@ app.get('/api/quotes/:id/html', async (req, res) => {
|
||||||
<td colspan="3" class="total-label">Subtotal:</td>
|
<td colspan="3" class="total-label">Subtotal:</td>
|
||||||
<td class="total-amount">$${parseFloat(quote.subtotal).toFixed(2)}</td>
|
<td class="total-amount">$${parseFloat(quote.subtotal).toFixed(2)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
if (!quote.tax_exempt) {
|
if (!quote.tax_exempt) {
|
||||||
itemsHTML += `
|
itemsHTML += `
|
||||||
<tr class="footer-row">
|
<tr class="footer-row">
|
||||||
|
|
@ -963,7 +978,6 @@ app.get('/api/quotes/:id/html', async (req, res) => {
|
||||||
<td class="total-amount">$${parseFloat(quote.tax_amount).toFixed(2)}</td>
|
<td class="total-amount">$${parseFloat(quote.tax_amount).toFixed(2)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
itemsHTML += `
|
itemsHTML += `
|
||||||
<tr class="footer-row">
|
<tr class="footer-row">
|
||||||
<td colspan="3" class="total-label">TOTAL:</td>
|
<td colspan="3" class="total-label">TOTAL:</td>
|
||||||
|
|
@ -973,15 +987,23 @@ app.get('/api/quotes/:id/html', async (req, res) => {
|
||||||
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
let tbdNote = '';
|
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
|
||||||
if (quote.has_tbd) {
|
|
||||||
tbdNote = '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD" (To Be Determined). The final total may vary once all details are finalized.</em></p>';
|
// --- ADRESS LOGIK ---
|
||||||
|
const addressLines = [];
|
||||||
|
if (quote.line1 && quote.line1.trim().toLowerCase() !== (quote.customer_name || '').trim().toLowerCase()) {
|
||||||
|
addressLines.push(quote.line1);
|
||||||
}
|
}
|
||||||
|
if (quote.line2) addressLines.push(quote.line2);
|
||||||
|
if (quote.line3) addressLines.push(quote.line3);
|
||||||
|
if (quote.line4) addressLines.push(quote.line4);
|
||||||
|
|
||||||
|
const streetBlock = addressLines.join('<br>');
|
||||||
|
|
||||||
html = html
|
html = html
|
||||||
.replace('{{LOGO_HTML}}', logoHTML)
|
.replace('{{LOGO_HTML}}', logoHTML)
|
||||||
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
||||||
.replace('{{CUSTOMER_STREET}}', quote.street || '')
|
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
||||||
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
||||||
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
||||||
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
||||||
|
|
@ -1004,8 +1026,9 @@ app.get('/api/invoices/:id/html', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// KORREKTUR: Line 1-4 abfragen
|
||||||
const invoiceResult = await pool.query(`
|
const invoiceResult = await pool.query(`
|
||||||
SELECT i.*, c.name as customer_name, c.street, c.city, c.state, c.zip_code, c.account_number
|
SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||||
FROM invoices i
|
FROM invoices i
|
||||||
LEFT JOIN customers c ON i.customer_id = c.id
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
WHERE i.id = $1
|
WHERE i.id = $1
|
||||||
|
|
@ -1036,9 +1059,7 @@ app.get('/api/invoices/:id/html', async (req, res) => {
|
||||||
let rateFormatted = item.rate;
|
let rateFormatted = item.rate;
|
||||||
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
||||||
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
||||||
if (!isNaN(rateNum)) {
|
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
||||||
rateFormatted = rateNum.toFixed(2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -1046,8 +1067,7 @@ app.get('/api/invoices/:id/html', async (req, res) => {
|
||||||
<td class="description">${item.description}</td>
|
<td class="description">${item.description}</td>
|
||||||
<td class="rate">${rateFormatted}</td>
|
<td class="rate">${rateFormatted}</td>
|
||||||
<td class="amount">${item.amount}</td>
|
<td class="amount">${item.amount}</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
`;
|
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
itemsHTML += `
|
itemsHTML += `
|
||||||
|
|
@ -1055,7 +1075,6 @@ app.get('/api/invoices/:id/html', async (req, res) => {
|
||||||
<td colspan="3" class="total-label">Subtotal:</td>
|
<td colspan="3" class="total-label">Subtotal:</td>
|
||||||
<td class="total-amount">$${parseFloat(invoice.subtotal).toFixed(2)}</td>
|
<td class="total-amount">$${parseFloat(invoice.subtotal).toFixed(2)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
if (!invoice.tax_exempt) {
|
if (!invoice.tax_exempt) {
|
||||||
itemsHTML += `
|
itemsHTML += `
|
||||||
<tr class="footer-row">
|
<tr class="footer-row">
|
||||||
|
|
@ -1063,7 +1082,6 @@ app.get('/api/invoices/:id/html', async (req, res) => {
|
||||||
<td class="total-amount">$${parseFloat(invoice.tax_amount).toFixed(2)}</td>
|
<td class="total-amount">$${parseFloat(invoice.tax_amount).toFixed(2)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
itemsHTML += `
|
itemsHTML += `
|
||||||
<tr class="footer-row">
|
<tr class="footer-row">
|
||||||
<td colspan="3" class="total-label">TOTAL:</td>
|
<td colspan="3" class="total-label">TOTAL:</td>
|
||||||
|
|
@ -1073,13 +1091,23 @@ app.get('/api/invoices/:id/html', async (req, res) => {
|
||||||
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
const authHTML = invoice.auth_code ?
|
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
|
||||||
`<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
|
|
||||||
|
// --- ADRESS LOGIK ---
|
||||||
|
const addressLines = [];
|
||||||
|
if (invoice.line1 && invoice.line1.trim().toLowerCase() !== (invoice.customer_name || '').trim().toLowerCase()) {
|
||||||
|
addressLines.push(invoice.line1);
|
||||||
|
}
|
||||||
|
if (invoice.line2) addressLines.push(invoice.line2);
|
||||||
|
if (invoice.line3) addressLines.push(invoice.line3);
|
||||||
|
if (invoice.line4) addressLines.push(invoice.line4);
|
||||||
|
|
||||||
|
const streetBlock = addressLines.join('<br>');
|
||||||
|
|
||||||
html = html
|
html = html
|
||||||
.replace('{{LOGO_HTML}}', logoHTML)
|
.replace('{{LOGO_HTML}}', logoHTML)
|
||||||
.replace('{{CUSTOMER_NAME}}', invoice.customer_name || '')
|
.replace('{{CUSTOMER_NAME}}', invoice.customer_name || '')
|
||||||
.replace('{{CUSTOMER_STREET}}', invoice.street || '')
|
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
||||||
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
|
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
|
||||||
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
|
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
|
||||||
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
|
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
|
||||||
|
|
@ -1099,6 +1127,463 @@ app.get('/api/invoices/:id/html', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// QBO Export Endpoint
|
||||||
|
app.post('/api/invoices/:id/export', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
// IDs für deine Items (Labor / Parts)
|
||||||
|
const QBO_LABOR_ID = '5';
|
||||||
|
const QBO_PARTS_ID = '9';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Lokale Rechnung laden
|
||||||
|
const invoiceRes = await client.query(`
|
||||||
|
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
|
WHERE i.id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (invoiceRes.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
|
||||||
|
const invoice = invoiceRes.rows[0];
|
||||||
|
|
||||||
|
if (!invoice.customer_qbo_id) {
|
||||||
|
return res.status(400).json({ error: `Kunde "${invoice.customer_name}" ist noch nicht mit QBO verknüpft.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Items laden
|
||||||
|
const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [id]);
|
||||||
|
const items = itemsRes.rows;
|
||||||
|
|
||||||
|
// 3. QBO Client vorbereiten
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
|
? 'https://quickbooks.api.intuit.com'
|
||||||
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
|
// --- SCHRITT 3a: NÄCHSTE NUMMER ERMITTELN ---
|
||||||
|
console.log("🔍 Frage QBO nach der letzten Rechnungsnummer...");
|
||||||
|
|
||||||
|
// Wir suchen die ZULETZT ERSTELLTE Rechnung
|
||||||
|
const lastNumQuery = "SELECT DocNumber FROM Invoice ORDERBY MetaData.CreateTime DESC MAXRESULTS 1";
|
||||||
|
const lastNumResponse = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(lastNumQuery)}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastNumData = lastNumResponse.getJson ? lastNumResponse.getJson() : lastNumResponse.json;
|
||||||
|
let nextDocNumber = null;
|
||||||
|
|
||||||
|
if (lastNumData.QueryResponse && lastNumData.QueryResponse.Invoice && lastNumData.QueryResponse.Invoice.length > 0) {
|
||||||
|
const lastDocNumberStr = lastNumData.QueryResponse.Invoice[0].DocNumber;
|
||||||
|
// Versuchen, die Nummer zu parsen (Entfernt Buchstaben, behält Zahlen)
|
||||||
|
const lastNum = parseInt(lastDocNumberStr.replace(/[^0-9]/g, ''), 10);
|
||||||
|
|
||||||
|
if (!isNaN(lastNum)) {
|
||||||
|
nextDocNumber = (lastNum + 1).toString();
|
||||||
|
console.log(`✅ Letzte Nummer war ${lastDocNumberStr}. Neue Nummer wird: ${nextDocNumber}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Wenn QBO leer ist oder Parsing fehlschlägt, nimm die lokale Nummer
|
||||||
|
if (!nextDocNumber) {
|
||||||
|
console.log("⚠️ Konnte keine Nummer aus QBO ermitteln. Verwende lokale Nummer.");
|
||||||
|
nextDocNumber = invoice.invoice_number;
|
||||||
|
}
|
||||||
|
// -------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
// 4. QBO JSON bauen
|
||||||
|
const lineItems = items.map(item => {
|
||||||
|
const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0;
|
||||||
|
const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0;
|
||||||
|
|
||||||
|
const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
|
||||||
|
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
|
||||||
|
|
||||||
|
return {
|
||||||
|
"DetailType": "SalesItemLineDetail",
|
||||||
|
"Amount": amount,
|
||||||
|
"Description": item.description,
|
||||||
|
"SalesItemLineDetail": {
|
||||||
|
"ItemRef": { "value": itemRefId, "name": itemRefName },
|
||||||
|
"UnitPrice": rate,
|
||||||
|
"Qty": parseFloat(item.quantity) || 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const qboInvoicePayload = {
|
||||||
|
"CustomerRef": { "value": invoice.customer_qbo_id },
|
||||||
|
|
||||||
|
// HIER SETZEN WIR DIE ERMITTELTE NUMMER EIN
|
||||||
|
"DocNumber": nextDocNumber,
|
||||||
|
|
||||||
|
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
|
||||||
|
"Line": lineItems,
|
||||||
|
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" },
|
||||||
|
|
||||||
|
// Status auf "Verschickt" setzen
|
||||||
|
"EmailStatus": "EmailSent",
|
||||||
|
"BillEmail": { "Address": invoice.email || "" }
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`📤 Sende Rechnung an QBO (DocNumber: ${nextDocNumber})...`);
|
||||||
|
|
||||||
|
const createResponse = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/invoice`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(qboInvoicePayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseData = createResponse.getJson ? createResponse.getJson() : createResponse.json;
|
||||||
|
// Check auf Unterobjekt "Invoice"
|
||||||
|
const qboInvoice = responseData.Invoice || responseData;
|
||||||
|
|
||||||
|
console.log("🔍 FULL QBO RESPONSE (ID):", qboInvoice.Id);
|
||||||
|
|
||||||
|
if (!qboInvoice.Id) {
|
||||||
|
console.error("FULL RESPONSE DUMP:", JSON.stringify(responseData, null, 2));
|
||||||
|
throw new Error("QBO hat keine ID zurückgegeben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ QBO Rechnung erstellt! ID: ${qboInvoice.Id}, DocNumber: ${qboInvoice.DocNumber}`);
|
||||||
|
|
||||||
|
// 6. DB Update: Wir speichern AUCH die QBO-Nummer, damit wir wissen, wie sie drüben heißt
|
||||||
|
await client.query(
|
||||||
|
`UPDATE invoices SET qbo_id = $1, qbo_sync_token = $2, qbo_doc_number = $3, invoice_number = $3 WHERE id = $4`,
|
||||||
|
[qboInvoice.Id, qboInvoice.SyncToken, qboInvoice.DocNumber, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wir geben die neue Nummer zurück, damit das Frontend Bescheid weiß
|
||||||
|
res.json({ success: true, qbo_id: qboInvoice.Id, qbo_doc_number: qboInvoice.DocNumber });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("QBO Export Error:", error);
|
||||||
|
let errorDetails = error.message;
|
||||||
|
if (error.response?.data?.Fault?.Error?.[0]) {
|
||||||
|
errorDetails = error.response.data.Fault.Error[0].Message + ": " + error.response.data.Fault.Error[0].Detail;
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: "QBO Export failed: " + errorDetails });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/qbo/overdue', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Datum vor 30 Tagen berechnen
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - 30);
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
console.log(`🔍 Suche in QBO nach unbezahlten Rechnungen fällig vor ${dateStr}...`);
|
||||||
|
|
||||||
|
// Query: Offene Rechnungen, deren Fälligkeitsdatum älter als 30 Tage ist
|
||||||
|
const query = `SELECT DocNumber, TxnDate, DueDate, Balance, CustomerRef, TotalAmt FROM Invoice WHERE Balance > '0' AND DueDate < '${dateStr}' ORDERBY DueDate ASC`;
|
||||||
|
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
|
? 'https://quickbooks.api.intuit.com'
|
||||||
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
|
// makeQboApiCall kümmert sich um den Refresh, falls nötig!
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
const invoices = data.QueryResponse?.Invoice || [];
|
||||||
|
|
||||||
|
console.log(`✅ ${invoices.length} überfällige Rechnungen gefunden.`);
|
||||||
|
res.json(invoices);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("QBO Report Error:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Schritt 1: User klickt "Authorize" → Redirect zu Intuit
|
||||||
|
app.get('/auth/qbo', (req, res) => {
|
||||||
|
const client = getOAuthClient();
|
||||||
|
const authUri = client.authorizeUri({
|
||||||
|
scope: [OAuthClient.scopes.Accounting],
|
||||||
|
state: 'intuit-qbo-auth'
|
||||||
|
});
|
||||||
|
console.log('🔗 Redirecting to QBO Authorization:', authUri);
|
||||||
|
res.redirect(authUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schritt 2: Intuit redirected zurück mit Code → Token holen
|
||||||
|
app.get('/auth/qbo/callback', async (req, res) => {
|
||||||
|
const client = getOAuthClient();
|
||||||
|
try {
|
||||||
|
const authResponse = await client.createToken(req.url);
|
||||||
|
console.log('✅ QBO Authorization erfolgreich!');
|
||||||
|
saveTokens();
|
||||||
|
|
||||||
|
// Redirect zurück zur App (Settings Tab)
|
||||||
|
res.redirect('/#settings');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ QBO Authorization fehlgeschlagen:', e);
|
||||||
|
res.status(500).send(`
|
||||||
|
<h2>QBO Authorization Failed</h2>
|
||||||
|
<p>${e.message || e}</p>
|
||||||
|
<a href="/">Zurück zur App</a>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status-Check Endpoint (für die UI)
|
||||||
|
app.get('/api/qbo/status', (req, res) => {
|
||||||
|
try {
|
||||||
|
const client = getOAuthClient();
|
||||||
|
const token = client.getToken();
|
||||||
|
const hasToken = !!(token && token.refresh_token);
|
||||||
|
res.json({
|
||||||
|
connected: hasToken,
|
||||||
|
realmId: token?.realmId || null
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.json({ connected: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/qbo/import-unpaid', async (req, res) => {
|
||||||
|
const dbClient = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
|
? 'https://quickbooks.api.intuit.com'
|
||||||
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
|
// 1. Alle unbezahlten Rechnungen aus QBO holen
|
||||||
|
// Balance > '0' = noch nicht vollständig bezahlt
|
||||||
|
// MAXRESULTS 1000 = sicherheitshalber hoch setzen
|
||||||
|
console.log('📥 QBO Import: Lade unbezahlte Rechnungen...');
|
||||||
|
|
||||||
|
const query = "SELECT * FROM Invoice WHERE Balance > '0' ORDERBY DocNumber ASC MAXRESULTS 1000";
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
const qboInvoices = data.QueryResponse?.Invoice || [];
|
||||||
|
|
||||||
|
console.log(`📋 ${qboInvoices.length} unbezahlte Rechnungen in QBO gefunden.`);
|
||||||
|
|
||||||
|
if (qboInvoices.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
imported: 0,
|
||||||
|
skipped: 0,
|
||||||
|
skippedNoCustomer: 0,
|
||||||
|
message: 'Keine unbezahlten Rechnungen in QBO gefunden.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Lokale Kunden laden (die mit QBO verknüpft sind)
|
||||||
|
const customersResult = await dbClient.query(
|
||||||
|
'SELECT id, qbo_id, name, taxable FROM customers WHERE qbo_id IS NOT NULL'
|
||||||
|
);
|
||||||
|
const customerMap = new Map();
|
||||||
|
customersResult.rows.forEach(c => customerMap.set(c.qbo_id, c));
|
||||||
|
|
||||||
|
// 3. Bereits importierte QBO-Rechnungen ermitteln (nach qbo_id)
|
||||||
|
const existingResult = await dbClient.query(
|
||||||
|
'SELECT qbo_id FROM invoices WHERE qbo_id IS NOT NULL'
|
||||||
|
);
|
||||||
|
const existingQboIds = new Set(existingResult.rows.map(r => r.qbo_id));
|
||||||
|
|
||||||
|
// 4. Import durchführen
|
||||||
|
let imported = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let skippedNoCustomer = 0;
|
||||||
|
const skippedCustomerNames = [];
|
||||||
|
|
||||||
|
await dbClient.query('BEGIN');
|
||||||
|
|
||||||
|
for (const qboInv of qboInvoices) {
|
||||||
|
const qboId = String(qboInv.Id);
|
||||||
|
|
||||||
|
// Bereits importiert? → Überspringen
|
||||||
|
if (existingQboIds.has(qboId)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kunde lokal vorhanden?
|
||||||
|
const customerQboId = String(qboInv.CustomerRef?.value || '');
|
||||||
|
const localCustomer = customerMap.get(customerQboId);
|
||||||
|
|
||||||
|
if (!localCustomer) {
|
||||||
|
skippedNoCustomer++;
|
||||||
|
const custName = qboInv.CustomerRef?.name || 'Unbekannt';
|
||||||
|
if (!skippedCustomerNames.includes(custName)) {
|
||||||
|
skippedCustomerNames.push(custName);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Werte aus QBO-Rechnung extrahieren
|
||||||
|
const docNumber = qboInv.DocNumber || '';
|
||||||
|
const txnDate = qboInv.TxnDate || new Date().toISOString().split('T')[0];
|
||||||
|
const syncToken = qboInv.SyncToken || '';
|
||||||
|
|
||||||
|
// Terms aus QBO mappen (SalesTermRef)
|
||||||
|
let terms = 'Net 30';
|
||||||
|
if (qboInv.SalesTermRef?.name) {
|
||||||
|
terms = qboInv.SalesTermRef.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tax: Prüfen ob TaxLine vorhanden
|
||||||
|
const taxAmount = qboInv.TxnTaxDetail?.TotalTax || 0;
|
||||||
|
const taxExempt = taxAmount === 0;
|
||||||
|
|
||||||
|
// Subtotal berechnen (Total - Tax)
|
||||||
|
const total = parseFloat(qboInv.TotalAmt) || 0;
|
||||||
|
const subtotal = total - taxAmount;
|
||||||
|
const taxRate = subtotal > 0 && !taxExempt ? (taxAmount / subtotal * 100) : 8.25;
|
||||||
|
|
||||||
|
// Memo als auth_code (falls vorhanden)
|
||||||
|
const authCode = qboInv.CustomerMemo?.value || '';
|
||||||
|
|
||||||
|
// Rechnung einfügen
|
||||||
|
const invoiceResult = await dbClient.query(
|
||||||
|
`INSERT INTO invoices
|
||||||
|
(invoice_number, customer_id, invoice_date, terms, auth_code,
|
||||||
|
tax_exempt, tax_rate, subtotal, tax_amount, total,
|
||||||
|
qbo_id, qbo_sync_token, qbo_doc_number)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING id`,
|
||||||
|
[docNumber, localCustomer.id, txnDate, terms, authCode,
|
||||||
|
taxExempt, taxRate, subtotal, taxAmount, total,
|
||||||
|
qboId, syncToken, docNumber]
|
||||||
|
);
|
||||||
|
|
||||||
|
const localInvoiceId = invoiceResult.rows[0].id;
|
||||||
|
|
||||||
|
// Line Items importieren
|
||||||
|
const lines = qboInv.Line || [];
|
||||||
|
let itemOrder = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Nur SalesItemLineDetail-Zeilen (keine SubTotalLine etc.)
|
||||||
|
if (line.DetailType !== 'SalesItemLineDetail') continue;
|
||||||
|
|
||||||
|
const detail = line.SalesItemLineDetail || {};
|
||||||
|
const qty = String(detail.Qty || 1);
|
||||||
|
const rate = String(detail.UnitPrice || 0);
|
||||||
|
const amount = String(line.Amount || 0);
|
||||||
|
const description = line.Description || '';
|
||||||
|
|
||||||
|
// Item-Typ ermitteln (Labor=5, Parts=9)
|
||||||
|
const itemRefValue = detail.ItemRef?.value || '9';
|
||||||
|
const itemRefName = (detail.ItemRef?.name || '').toLowerCase();
|
||||||
|
let qboItemId = '9'; // Default: Parts
|
||||||
|
if (itemRefValue === '5' || itemRefName.includes('labor')) {
|
||||||
|
qboItemId = '5';
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbClient.query(
|
||||||
|
`INSERT INTO invoice_items
|
||||||
|
(invoice_id, quantity, description, rate, amount, item_order, qbo_item_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
[localInvoiceId, qty, description, rate, amount, itemOrder, qboItemId]
|
||||||
|
);
|
||||||
|
itemOrder++;
|
||||||
|
}
|
||||||
|
|
||||||
|
imported++;
|
||||||
|
console.log(` ✅ Importiert: #${docNumber} (${localCustomer.name}) - $${total}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbClient.query('COMMIT');
|
||||||
|
|
||||||
|
const message = [
|
||||||
|
`${imported} Rechnungen importiert.`,
|
||||||
|
skipped > 0 ? `${skipped} bereits vorhanden (übersprungen).` : '',
|
||||||
|
skippedNoCustomer > 0 ? `${skippedNoCustomer} übersprungen (Kunde nicht verknüpft: ${skippedCustomerNames.join(', ')}).` : ''
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
console.log(`📥 QBO Import abgeschlossen: ${message}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
imported,
|
||||||
|
skipped,
|
||||||
|
skippedNoCustomer,
|
||||||
|
skippedCustomerNames,
|
||||||
|
message
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await dbClient.query('ROLLBACK');
|
||||||
|
console.error('❌ QBO Import Error:', error);
|
||||||
|
res.status(500).json({ error: 'Import fehlgeschlagen: ' + error.message });
|
||||||
|
} finally {
|
||||||
|
dbClient.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark invoice as paid
|
||||||
|
app.patch('/api/invoices/:id/mark-paid', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { paid_date } = req.body; // Optional: explizites Datum, sonst heute
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dateToUse = paid_date || new Date().toISOString().split('T')[0];
|
||||||
|
const result = await pool.query(
|
||||||
|
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *',
|
||||||
|
[dateToUse, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Invoice not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`💰 Invoice #${result.rows[0].invoice_number} als bezahlt markiert (${dateToUse})`);
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking invoice as paid:', error);
|
||||||
|
res.status(500).json({ error: 'Error marking invoice as paid' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark invoice as unpaid
|
||||||
|
app.patch('/api/invoices/:id/mark-unpaid', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
'UPDATE invoices SET paid_date = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING *',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Invoice not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`↩️ Invoice #${result.rows[0].invoice_number} als unbezahlt markiert`);
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking invoice as unpaid:', error);
|
||||||
|
res.status(500).json({ error: 'Error marking invoice as unpaid' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Start server and browser
|
// Start server and browser
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// =====================================================
|
||||||
|
// set_qbo_token.js
|
||||||
|
//
|
||||||
|
// Einmalig ausführen um qbo_token.json korrekt zu setzen.
|
||||||
|
// Die intuit-oauth Library braucht ein vollständiges Token-Objekt,
|
||||||
|
// nicht nur access_token + refresh_token.
|
||||||
|
//
|
||||||
|
// Verwendung:
|
||||||
|
// node set_qbo_token.js <ACCESS_TOKEN> <REFRESH_TOKEN> <REALM_ID>
|
||||||
|
//
|
||||||
|
// Beispiel:
|
||||||
|
// node set_qbo_token.js "eyJlbmMi..." "AB11..." "9341..."
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const accessToken = process.argv[2];
|
||||||
|
const refreshToken = process.argv[3];
|
||||||
|
const realmId = process.argv[4];
|
||||||
|
|
||||||
|
if (!accessToken || !refreshToken || !realmId) {
|
||||||
|
console.log('');
|
||||||
|
console.log('Verwendung:');
|
||||||
|
console.log(' node set_qbo_token.js <ACCESS_TOKEN> <REFRESH_TOKEN> <REALM_ID>');
|
||||||
|
console.log('');
|
||||||
|
console.log('Die Werte bekommst du aus dem Intuit OAuth Playground:');
|
||||||
|
console.log(' https://developer.intuit.com/app/developer/playground');
|
||||||
|
console.log('');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das ist das Format, das die intuit-oauth Library erwartet
|
||||||
|
const tokenObject = {
|
||||||
|
token_type: "bearer",
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
expires_in: 3600,
|
||||||
|
x_refresh_token_expires_in: 8726400,
|
||||||
|
realmId: realmId,
|
||||||
|
// createdAt wird von der Library geprüft um zu sehen ob der Token abgelaufen ist
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenFile = path.join(__dirname, 'qbo_token.json');
|
||||||
|
fs.writeFileSync(tokenFile, JSON.stringify(tokenObject, null, 2));
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('✅ qbo_token.json erfolgreich erstellt!');
|
||||||
|
console.log(` 📁 ${tokenFile}`);
|
||||||
|
console.log(` 🔑 Access Token: ${accessToken.substring(0, 20)}...`);
|
||||||
|
console.log(` 🔄 Refresh Token: ${refreshToken.substring(0, 15)}...`);
|
||||||
|
console.log(` 🏢 Realm ID: ${realmId}`);
|
||||||
|
console.log('');
|
||||||
|
console.log('Nächste Schritte:');
|
||||||
|
console.log(' 1. Docker Container neu starten: docker compose restart quote_app');
|
||||||
|
console.log(' 2. In Settings → "Test Connection" klicken');
|
||||||
|
console.log('');
|
||||||
|
|
@ -86,7 +86,11 @@
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.info-div {
|
||||||
|
display: flex;
|
||||||
|
height: fit-content;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
.info-table {
|
.info-table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
@ -110,7 +114,7 @@
|
||||||
.items-table {
|
.items-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin: 20px 0;
|
margin: 40px 0 20px 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -238,6 +242,7 @@
|
||||||
{{CUSTOMER_CITY}}, {{CUSTOMER_STATE}} {{CUSTOMER_ZIP}}
|
{{CUSTOMER_CITY}}, {{CUSTOMER_STATE}} {{CUSTOMER_ZIP}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="info-div">
|
||||||
<table class="info-table">
|
<table class="info-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -257,6 +262,7 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{AUTHORIZATION}}
|
{{AUTHORIZATION}}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue