@invoicer/cli 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +15 -1
- package/dist/cli.js.map +1 -1
- package/index.html +2584 -0
- package/package.json +2 -1
package/index.html
ADDED
|
@@ -0,0 +1,2584 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<!--
|
|
4
|
+
============================================================================
|
|
5
|
+
INVOICER - Professional Invoice Generator with Gmail Integration
|
|
6
|
+
============================================================================
|
|
7
|
+
|
|
8
|
+
📧 GMAIL API SETUP (FOR AUTOMATIC PDF ATTACHMENT):
|
|
9
|
+
|
|
10
|
+
To enable automatic PDF email attachment, follow these steps:
|
|
11
|
+
|
|
12
|
+
1. Go to Google Cloud Console:
|
|
13
|
+
https://console.cloud.google.com/
|
|
14
|
+
|
|
15
|
+
2. Create a New Project:
|
|
16
|
+
- Click "Select a project" → "NEW PROJECT"
|
|
17
|
+
- Name it "Invoicer" or anything you like
|
|
18
|
+
- Click "CREATE"
|
|
19
|
+
|
|
20
|
+
3. Enable Gmail API:
|
|
21
|
+
- Go to "APIs & Services" → "Library"
|
|
22
|
+
- Search for "Gmail API"
|
|
23
|
+
- Click on it and click "ENABLE"
|
|
24
|
+
|
|
25
|
+
4. Create OAuth 2.0 Credentials:
|
|
26
|
+
- Go to "APIs & Services" → "Credentials"
|
|
27
|
+
- Click "CREATE CREDENTIALS" → "OAuth client ID"
|
|
28
|
+
- Configure consent screen if prompted:
|
|
29
|
+
* User Type: External
|
|
30
|
+
* App name: Invoicer
|
|
31
|
+
* User support email: your email
|
|
32
|
+
* Add scope: .../auth/gmail.send
|
|
33
|
+
* Add test users: your Gmail address
|
|
34
|
+
- Application type: "Web application"
|
|
35
|
+
- Name: "Invoicer"
|
|
36
|
+
- Authorized JavaScript origins:
|
|
37
|
+
* http://localhost
|
|
38
|
+
* http://127.0.0.1
|
|
39
|
+
* (Add your domain if hosted online)
|
|
40
|
+
- Click "CREATE"
|
|
41
|
+
|
|
42
|
+
5. Copy Your Client ID:
|
|
43
|
+
- Copy the "Client ID" (looks like: xxxxx.apps.googleusercontent.com)
|
|
44
|
+
|
|
45
|
+
6. Update the Code:
|
|
46
|
+
- Find line ~1243: CLIENT_ID: 'YOUR_CLIENT_ID_HERE...'
|
|
47
|
+
- Replace with your actual Client ID
|
|
48
|
+
- Save the file
|
|
49
|
+
|
|
50
|
+
7. That's it!
|
|
51
|
+
- Click "Connect Gmail"
|
|
52
|
+
- Sign in with your Google account
|
|
53
|
+
- Click "Send via Gmail" - PDF attaches automatically!
|
|
54
|
+
|
|
55
|
+
⚠️ WITHOUT SETUP: The app still works! It downloads PDF and opens Gmail,
|
|
56
|
+
you just need to attach the PDF manually (takes 5 seconds).
|
|
57
|
+
|
|
58
|
+
============================================================================
|
|
59
|
+
-->
|
|
60
|
+
<head>
|
|
61
|
+
<meta charset="UTF-8" />
|
|
62
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
63
|
+
<title>Invoicer – Professional Invoice Generator</title>
|
|
64
|
+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%231e293b'/%3E%3Ctext x='50' y='70' font-family='Arial, sans-serif' font-size='48' font-weight='900' fill='%23ffffff' text-anchor='middle' letter-spacing='-4'%3EIN%3C/text%3E%3C/svg%3E" />
|
|
65
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
|
|
66
|
+
<script src="https://apis.google.com/js/api.js"></script>
|
|
67
|
+
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
|
68
|
+
<style>
|
|
69
|
+
:root {
|
|
70
|
+
--ink: #1e293b; /* slate-800 */
|
|
71
|
+
--ink-light: #334155; /* slate-700 */
|
|
72
|
+
--muted: #64748b; /* slate-500 */
|
|
73
|
+
--line: #e2e8f0; /* slate-200 */
|
|
74
|
+
--line-light: #f1f5f9; /* slate-100 */
|
|
75
|
+
--bg: #f8fafc; /* slate-50 */
|
|
76
|
+
--card: #ffffff; /* white */
|
|
77
|
+
--accent: #1e293b; /* black/slate-800 */
|
|
78
|
+
--accent-hover: #0f172a; /* slate-900 */
|
|
79
|
+
--accent-ink: #0f172a; /* slate-900 */
|
|
80
|
+
--accent-light: #e2e8f0; /* slate-200 */
|
|
81
|
+
--success: #10b981;
|
|
82
|
+
--danger: #ef4444;
|
|
83
|
+
--warning: #f59e0b;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
* { box-sizing: border-box; }
|
|
87
|
+
html, body { height: 100%; }
|
|
88
|
+
body {
|
|
89
|
+
margin: 0;
|
|
90
|
+
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
|
|
91
|
+
color: var(--ink);
|
|
92
|
+
font: 14px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
93
|
+
-webkit-font-smoothing: antialiased;
|
|
94
|
+
-moz-osx-font-smoothing: grayscale;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.wrap {
|
|
98
|
+
max-width: 1400px;
|
|
99
|
+
margin: 0 auto;
|
|
100
|
+
padding: 16px;
|
|
101
|
+
display: grid;
|
|
102
|
+
grid-template-columns: 1fr;
|
|
103
|
+
gap: 16px;
|
|
104
|
+
min-height: 100vh;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* Desktop: side-by-side layout */
|
|
108
|
+
@media (min-width: 1024px) {
|
|
109
|
+
.wrap {
|
|
110
|
+
padding: 32px 20px;
|
|
111
|
+
grid-template-columns: 480px 1fr;
|
|
112
|
+
gap: 24px;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
h1 {
|
|
117
|
+
margin: 0 0 20px;
|
|
118
|
+
font-size: 32px;
|
|
119
|
+
font-weight: 800;
|
|
120
|
+
letter-spacing: -0.5px;
|
|
121
|
+
color: var(--accent-ink);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
h2 {
|
|
125
|
+
margin: 20px 0 12px;
|
|
126
|
+
padding-bottom: 8px;
|
|
127
|
+
font-size: 13px;
|
|
128
|
+
font-weight: 700;
|
|
129
|
+
text-transform: uppercase;
|
|
130
|
+
letter-spacing: 0.5px;
|
|
131
|
+
color: var(--ink-light);
|
|
132
|
+
border-bottom: 2px solid var(--line);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@media (min-width: 768px) {
|
|
136
|
+
h2 {
|
|
137
|
+
margin: 24px 0 16px;
|
|
138
|
+
font-size: 15px;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
h2:first-child {
|
|
143
|
+
margin-top: 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.card {
|
|
147
|
+
background: var(--card);
|
|
148
|
+
border: none;
|
|
149
|
+
border-radius: 16px;
|
|
150
|
+
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1), 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
151
|
+
display: flex;
|
|
152
|
+
flex-direction: column;
|
|
153
|
+
overflow: hidden;
|
|
154
|
+
height: auto;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* Desktop: fixed height cards */
|
|
158
|
+
@media (min-width: 1024px) {
|
|
159
|
+
.card {
|
|
160
|
+
border-radius: 20px;
|
|
161
|
+
height: calc(100vh - 64px);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.card .hd {
|
|
166
|
+
padding: 18px 20px;
|
|
167
|
+
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
|
|
168
|
+
color: white;
|
|
169
|
+
display: flex;
|
|
170
|
+
justify-content: space-between;
|
|
171
|
+
align-items: center;
|
|
172
|
+
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.2);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@media (min-width: 768px) {
|
|
176
|
+
.card .hd {
|
|
177
|
+
padding: 24px 28px;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.card .hd > div:first-child {
|
|
182
|
+
font-size: 18px;
|
|
183
|
+
font-weight: 800;
|
|
184
|
+
letter-spacing: -0.3px;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
@media (min-width: 768px) {
|
|
188
|
+
.card .hd > div:first-child {
|
|
189
|
+
font-size: 20px;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.card .hd > div:last-child {
|
|
194
|
+
background: rgba(255, 255, 255, 0.2);
|
|
195
|
+
padding: 6px 14px;
|
|
196
|
+
border-radius: 20px;
|
|
197
|
+
font-size: 12px;
|
|
198
|
+
font-weight: 700;
|
|
199
|
+
backdrop-filter: blur(10px);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.card .bd {
|
|
203
|
+
padding: 20px 16px;
|
|
204
|
+
flex: 1;
|
|
205
|
+
overflow-y: auto;
|
|
206
|
+
overflow-x: hidden;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@media (min-width: 768px) {
|
|
210
|
+
.card .bd {
|
|
211
|
+
padding: 28px;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.card.inputs .bd {
|
|
216
|
+
background: white;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.card.preview .bd {
|
|
220
|
+
padding: 32px;
|
|
221
|
+
background: #f8fafc;
|
|
222
|
+
overflow-y: auto;
|
|
223
|
+
overflow-x: hidden;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* Custom scrollbar styling */
|
|
227
|
+
.card .bd::-webkit-scrollbar {
|
|
228
|
+
width: 8px;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.card .bd::-webkit-scrollbar-track {
|
|
232
|
+
background: transparent;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.card .bd::-webkit-scrollbar-thumb {
|
|
236
|
+
background: rgba(0, 0, 0, 0.1);
|
|
237
|
+
border-radius: 4px;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.card .bd::-webkit-scrollbar-thumb:hover {
|
|
241
|
+
background: rgba(0, 0, 0, 0.2);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* Firefox scrollbar */
|
|
245
|
+
.card .bd {
|
|
246
|
+
scrollbar-width: thin;
|
|
247
|
+
scrollbar-color: rgba(0, 0, 0, 0.1) transparent;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
label {
|
|
251
|
+
display: block;
|
|
252
|
+
font-size: 11px;
|
|
253
|
+
font-weight: 700;
|
|
254
|
+
text-transform: uppercase;
|
|
255
|
+
letter-spacing: 0.8px;
|
|
256
|
+
color: var(--muted);
|
|
257
|
+
margin: 0 0 8px 4px;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
@media (min-width: 768px) {
|
|
261
|
+
label {
|
|
262
|
+
font-size: 11px;
|
|
263
|
+
margin: 0 0 8px 0;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
input, select, textarea {
|
|
268
|
+
width: 100%;
|
|
269
|
+
height: 48px;
|
|
270
|
+
padding: 14px 16px;
|
|
271
|
+
border: 2px solid var(--line);
|
|
272
|
+
border-radius: 12px;
|
|
273
|
+
background: var(--card);
|
|
274
|
+
font-size: 16px; /* Prevents zoom on iOS */
|
|
275
|
+
font-weight: 500;
|
|
276
|
+
color: var(--ink);
|
|
277
|
+
transition: all 0.2s ease;
|
|
278
|
+
-webkit-appearance: none;
|
|
279
|
+
appearance: none;
|
|
280
|
+
box-sizing: border-box;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
textarea {
|
|
284
|
+
height: auto;
|
|
285
|
+
min-height: 80px;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
@media (min-width: 768px) {
|
|
289
|
+
input, select, textarea {
|
|
290
|
+
height: 44px;
|
|
291
|
+
padding: 12px 16px;
|
|
292
|
+
font-size: 14px;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
textarea {
|
|
296
|
+
height: auto;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
input:hover, select:hover, textarea:hover {
|
|
301
|
+
border-color: var(--accent);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
input:focus, select:focus, textarea:focus {
|
|
305
|
+
outline: none;
|
|
306
|
+
border-color: var(--accent);
|
|
307
|
+
box-shadow: 0 0 0 4px var(--accent-light);
|
|
308
|
+
background: white;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
input[type="number"] {
|
|
312
|
+
font-family: 'SF Mono', 'Monaco', 'Courier New', monospace;
|
|
313
|
+
font-weight: 600;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
input[type="color"] {
|
|
317
|
+
padding: 6px;
|
|
318
|
+
cursor: pointer;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
textarea {
|
|
322
|
+
resize: vertical;
|
|
323
|
+
font-family: inherit;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
input:disabled,
|
|
327
|
+
input[readonly] {
|
|
328
|
+
background: var(--line-light);
|
|
329
|
+
color: var(--muted);
|
|
330
|
+
cursor: not-allowed;
|
|
331
|
+
opacity: 0.8;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.row {
|
|
335
|
+
display: grid;
|
|
336
|
+
grid-template-columns: 1fr;
|
|
337
|
+
gap: 16px;
|
|
338
|
+
margin-bottom: 16px;
|
|
339
|
+
align-items: start;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
@media (min-width: 640px) {
|
|
343
|
+
.row {
|
|
344
|
+
grid-template-columns: 1fr 1fr;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.row-3 {
|
|
349
|
+
display: grid;
|
|
350
|
+
grid-template-columns: 1fr;
|
|
351
|
+
gap: 16px;
|
|
352
|
+
margin-bottom: 16px;
|
|
353
|
+
align-items: start;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
@media (min-width: 480px) {
|
|
357
|
+
.row-3 {
|
|
358
|
+
grid-template-columns: 1fr 1fr;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
@media (min-width: 768px) {
|
|
363
|
+
.row-3 {
|
|
364
|
+
grid-template-columns: 1fr 1fr 1fr;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.row-full {
|
|
369
|
+
display: block;
|
|
370
|
+
margin-bottom: 16px;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/* Ensure all form groups have consistent structure */
|
|
374
|
+
.row > div,
|
|
375
|
+
.row-3 > div,
|
|
376
|
+
.row-full > div {
|
|
377
|
+
display: flex;
|
|
378
|
+
flex-direction: column;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
.actions {
|
|
382
|
+
display: flex;
|
|
383
|
+
flex-direction: column;
|
|
384
|
+
gap: 12px;
|
|
385
|
+
margin-top: 24px;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
@media (min-width: 768px) {
|
|
389
|
+
.actions {
|
|
390
|
+
flex-direction: row;
|
|
391
|
+
flex-wrap: wrap;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
button {
|
|
396
|
+
cursor: pointer;
|
|
397
|
+
border: none;
|
|
398
|
+
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
|
|
399
|
+
color: white;
|
|
400
|
+
padding: 16px 24px;
|
|
401
|
+
border-radius: 12px;
|
|
402
|
+
font-weight: 700;
|
|
403
|
+
font-size: 15px;
|
|
404
|
+
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
405
|
+
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.4);
|
|
406
|
+
position: relative;
|
|
407
|
+
overflow: hidden;
|
|
408
|
+
min-height: 48px; /* Touch-friendly */
|
|
409
|
+
-webkit-tap-highlight-color: transparent;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
@media (min-width: 768px) {
|
|
413
|
+
button {
|
|
414
|
+
padding: 14px 24px;
|
|
415
|
+
font-size: 14px;
|
|
416
|
+
min-height: auto;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
button:before {
|
|
421
|
+
content: '';
|
|
422
|
+
position: absolute;
|
|
423
|
+
top: 0;
|
|
424
|
+
left: 0;
|
|
425
|
+
width: 100%;
|
|
426
|
+
height: 100%;
|
|
427
|
+
background: linear-gradient(135deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0) 100%);
|
|
428
|
+
opacity: 0;
|
|
429
|
+
transition: opacity 0.2s;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
button:hover:before {
|
|
433
|
+
opacity: 1;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
button:hover {
|
|
437
|
+
transform: translateY(-2px);
|
|
438
|
+
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.5);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
button:active {
|
|
442
|
+
transform: translateY(0);
|
|
443
|
+
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.4);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
button.secondary {
|
|
447
|
+
background: white;
|
|
448
|
+
color: var(--accent);
|
|
449
|
+
border: 2px solid var(--line);
|
|
450
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
button.secondary:hover {
|
|
454
|
+
background: var(--line-light);
|
|
455
|
+
border-color: var(--accent);
|
|
456
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
button.danger {
|
|
460
|
+
background: linear-gradient(135deg, var(--danger) 0%, #dc2626 100%);
|
|
461
|
+
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
button.danger:hover {
|
|
465
|
+
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.4);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
button:disabled {
|
|
469
|
+
opacity: 0.5;
|
|
470
|
+
cursor: not-allowed;
|
|
471
|
+
transform: none !important;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
.muted {
|
|
475
|
+
color: var(--muted);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.section-divider {
|
|
479
|
+
border: none;
|
|
480
|
+
border-top: 2px solid var(--line);
|
|
481
|
+
margin: 32px 0;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.line-items {
|
|
485
|
+
margin: 20px 0;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
.line-item {
|
|
489
|
+
background: white;
|
|
490
|
+
border: 2px solid var(--line);
|
|
491
|
+
border-radius: 16px;
|
|
492
|
+
padding: 24px;
|
|
493
|
+
margin-bottom: 16px;
|
|
494
|
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
495
|
+
position: relative;
|
|
496
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
@media (min-width: 768px) {
|
|
500
|
+
.line-item {
|
|
501
|
+
padding: 24px 28px;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
.line-item:hover {
|
|
506
|
+
border-color: var(--accent);
|
|
507
|
+
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
|
508
|
+
transform: translateY(-2px);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
.line-item-header {
|
|
512
|
+
display: flex;
|
|
513
|
+
justify-content: space-between;
|
|
514
|
+
align-items: center;
|
|
515
|
+
margin-bottom: 20px;
|
|
516
|
+
padding-bottom: 16px;
|
|
517
|
+
border-bottom: 2px solid var(--line-light);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
.line-item-title {
|
|
521
|
+
font-weight: 700;
|
|
522
|
+
font-size: 14px;
|
|
523
|
+
color: var(--ink-light);
|
|
524
|
+
display: flex;
|
|
525
|
+
align-items: center;
|
|
526
|
+
gap: 10px;
|
|
527
|
+
text-transform: uppercase;
|
|
528
|
+
letter-spacing: 0.5px;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
@media (min-width: 768px) {
|
|
532
|
+
.line-item-title {
|
|
533
|
+
font-size: 15px;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
.line-item-actions {
|
|
538
|
+
display: flex;
|
|
539
|
+
gap: 8px;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
.line-item-actions button {
|
|
543
|
+
padding: 8px 18px;
|
|
544
|
+
font-size: 13px;
|
|
545
|
+
font-weight: 600;
|
|
546
|
+
box-shadow: none;
|
|
547
|
+
background: white;
|
|
548
|
+
color: var(--danger);
|
|
549
|
+
border: 2px solid var(--line);
|
|
550
|
+
min-height: 36px;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
.line-item-actions button:hover {
|
|
554
|
+
background: var(--danger);
|
|
555
|
+
color: white;
|
|
556
|
+
border-color: var(--danger);
|
|
557
|
+
transform: none;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
@media (min-width: 768px) {
|
|
561
|
+
.line-item-actions button {
|
|
562
|
+
padding: 8px 20px;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
.badge {
|
|
567
|
+
display: inline-flex;
|
|
568
|
+
align-items: center;
|
|
569
|
+
justify-content: center;
|
|
570
|
+
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
|
|
571
|
+
color: white;
|
|
572
|
+
min-width: 28px;
|
|
573
|
+
height: 28px;
|
|
574
|
+
padding: 0 10px;
|
|
575
|
+
border-radius: 8px;
|
|
576
|
+
font-size: 13px;
|
|
577
|
+
font-weight: 800;
|
|
578
|
+
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.3);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/* Invoice preview styles */
|
|
582
|
+
.sheet {
|
|
583
|
+
background: white;
|
|
584
|
+
border: none;
|
|
585
|
+
border-radius: 8px;
|
|
586
|
+
padding: 40px 32px 80px 32px;
|
|
587
|
+
width: 100%;
|
|
588
|
+
max-width: 100%;
|
|
589
|
+
min-height: auto;
|
|
590
|
+
margin: 0 auto;
|
|
591
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
592
|
+
position: relative;
|
|
593
|
+
-webkit-font-smoothing: antialiased;
|
|
594
|
+
-moz-osx-font-smoothing: grayscale;
|
|
595
|
+
text-rendering: optimizeLegibility;
|
|
596
|
+
display: flex;
|
|
597
|
+
flex-direction: column;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
@media (min-width: 768px) {
|
|
601
|
+
.sheet {
|
|
602
|
+
border-radius: 12px;
|
|
603
|
+
padding: 48px 40px 100px 40px;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
@media (min-width: 1024px) {
|
|
608
|
+
.sheet {
|
|
609
|
+
padding: 60px 60px 120px 60px;
|
|
610
|
+
width: 210mm;
|
|
611
|
+
min-height: 297mm;
|
|
612
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
.inv-header {
|
|
617
|
+
display: flex;
|
|
618
|
+
flex-direction: column;
|
|
619
|
+
gap: 24px;
|
|
620
|
+
margin-bottom: 40px;
|
|
621
|
+
padding-bottom: 24px;
|
|
622
|
+
border-bottom: 3px solid var(--line);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
@media (min-width: 768px) {
|
|
626
|
+
.inv-header {
|
|
627
|
+
flex-direction: row;
|
|
628
|
+
justify-content: space-between;
|
|
629
|
+
align-items: flex-start;
|
|
630
|
+
margin-bottom: 48px;
|
|
631
|
+
padding-bottom: 28px;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
.brand {
|
|
636
|
+
display: flex;
|
|
637
|
+
align-items: center;
|
|
638
|
+
gap: 12px;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
.brand-badge {
|
|
642
|
+
width: 64px;
|
|
643
|
+
height: 64px;
|
|
644
|
+
border-radius: 16px;
|
|
645
|
+
background: var(--accent);
|
|
646
|
+
display: flex;
|
|
647
|
+
align-items: center;
|
|
648
|
+
justify-content: center;
|
|
649
|
+
color: #fff;
|
|
650
|
+
font-weight: 900;
|
|
651
|
+
font-size: 22px;
|
|
652
|
+
letter-spacing: -1px;
|
|
653
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
.brand-badge svg {
|
|
657
|
+
width: 44px;
|
|
658
|
+
height: 44px;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
.brand-title {
|
|
662
|
+
font-weight: 900;
|
|
663
|
+
font-size: 26px;
|
|
664
|
+
color: var(--accent-ink);
|
|
665
|
+
letter-spacing: 0.5px;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
@media (min-width: 768px) {
|
|
669
|
+
.brand-badge {
|
|
670
|
+
width: 72px;
|
|
671
|
+
height: 72px;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
.brand-badge svg {
|
|
675
|
+
width: 48px;
|
|
676
|
+
height: 48px;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.brand-title {
|
|
680
|
+
font-size: 32px;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
.meta {
|
|
685
|
+
width: 100%;
|
|
686
|
+
min-width: auto;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
@media (min-width: 768px) {
|
|
690
|
+
.meta {
|
|
691
|
+
min-width: 380px;
|
|
692
|
+
width: auto;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.meta .rowx {
|
|
697
|
+
display: flex;
|
|
698
|
+
border-bottom: 1px solid var(--line);
|
|
699
|
+
padding: 12px 0;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
.meta .rowx > div:first-child {
|
|
703
|
+
width: 45%;
|
|
704
|
+
font-weight: 700;
|
|
705
|
+
color: var(--muted);
|
|
706
|
+
font-size: 13px;
|
|
707
|
+
text-transform: uppercase;
|
|
708
|
+
letter-spacing: 0.5px;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
.meta .rowx > div:last-child {
|
|
712
|
+
flex: 1;
|
|
713
|
+
text-align: right;
|
|
714
|
+
font-weight: 700;
|
|
715
|
+
color: var(--ink);
|
|
716
|
+
font-size: 15px;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
@media (min-width: 768px) {
|
|
720
|
+
.meta .rowx {
|
|
721
|
+
padding: 14px 0;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.meta .rowx > div:first-child {
|
|
725
|
+
font-size: 14px;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
.meta .rowx > div:last-child {
|
|
729
|
+
font-size: 16px;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
.meta .rowx:last-child {
|
|
734
|
+
border-bottom: none;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
.grid {
|
|
738
|
+
display: grid;
|
|
739
|
+
grid-template-columns: 1fr 1fr;
|
|
740
|
+
gap: 24px;
|
|
741
|
+
margin: 32px 0 40px 0;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
.panel {
|
|
745
|
+
border: 2px solid var(--line);
|
|
746
|
+
border-radius: 16px;
|
|
747
|
+
padding: 20px;
|
|
748
|
+
background: #fafbff;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
.panel .title {
|
|
752
|
+
font-weight: 800;
|
|
753
|
+
color: var(--muted);
|
|
754
|
+
margin-bottom: 12px;
|
|
755
|
+
font-size: 13px;
|
|
756
|
+
text-transform: uppercase;
|
|
757
|
+
letter-spacing: 0.8px;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
.panel .content {
|
|
761
|
+
font-weight: 600;
|
|
762
|
+
line-height: 1.8;
|
|
763
|
+
color: var(--ink);
|
|
764
|
+
font-size: 14px;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
@media (min-width: 768px) {
|
|
768
|
+
.panel {
|
|
769
|
+
padding: 24px;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
.panel .title {
|
|
773
|
+
font-size: 14px;
|
|
774
|
+
margin-bottom: 14px;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
.panel .content {
|
|
778
|
+
font-size: 15px;
|
|
779
|
+
line-height: 1.9;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/* Table wrapper for mobile scroll */
|
|
784
|
+
.table-wrapper {
|
|
785
|
+
overflow-x: auto;
|
|
786
|
+
-webkit-overflow-scrolling: touch;
|
|
787
|
+
margin: 16px 0;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
table {
|
|
791
|
+
width: 100%;
|
|
792
|
+
min-width: 600px;
|
|
793
|
+
border-collapse: separate;
|
|
794
|
+
border-spacing: 0;
|
|
795
|
+
border: 2px solid var(--line);
|
|
796
|
+
border-radius: 12px;
|
|
797
|
+
overflow: hidden;
|
|
798
|
+
font-size: 13px;
|
|
799
|
+
margin: 32px 0;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
@media (min-width: 768px) {
|
|
803
|
+
table {
|
|
804
|
+
min-width: 100%;
|
|
805
|
+
border-radius: 14px;
|
|
806
|
+
margin: 40px 0;
|
|
807
|
+
font-size: 14px;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
th, td {
|
|
812
|
+
padding: 14px 12px;
|
|
813
|
+
border-bottom: 1px solid var(--line);
|
|
814
|
+
text-align: left;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
@media (min-width: 768px) {
|
|
818
|
+
th, td {
|
|
819
|
+
padding: 16px 18px;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
thead th {
|
|
824
|
+
background: var(--ink);
|
|
825
|
+
color: #fff;
|
|
826
|
+
font-weight: 700;
|
|
827
|
+
font-size: 11px;
|
|
828
|
+
text-transform: uppercase;
|
|
829
|
+
letter-spacing: 0.5px;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
@media (min-width: 768px) {
|
|
833
|
+
thead th {
|
|
834
|
+
font-size: 13px;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
tbody tr:last-child td {
|
|
839
|
+
border-bottom: none;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
tbody tr:hover {
|
|
843
|
+
background: #fafbff;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
td.right, th.right {
|
|
847
|
+
text-align: right;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
td.currency {
|
|
851
|
+
font-family: 'Courier New', monospace;
|
|
852
|
+
font-weight: 600;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
.totals {
|
|
856
|
+
display: flex;
|
|
857
|
+
justify-content: flex-end;
|
|
858
|
+
margin-top: 32px;
|
|
859
|
+
margin-bottom: 32px;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
@media (min-width: 768px) {
|
|
863
|
+
.totals {
|
|
864
|
+
margin-top: 40px;
|
|
865
|
+
margin-bottom: 48px;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
.totals .box {
|
|
870
|
+
width: 100%;
|
|
871
|
+
min-width: auto;
|
|
872
|
+
border: 1px solid var(--ink);
|
|
873
|
+
border-radius: 12px;
|
|
874
|
+
overflow: hidden;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
@media (min-width: 768px) {
|
|
878
|
+
.totals .box {
|
|
879
|
+
min-width: 360px;
|
|
880
|
+
width: auto;
|
|
881
|
+
border-radius: 14px;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
.totals .box .rowy {
|
|
886
|
+
display: flex;
|
|
887
|
+
justify-content: space-between;
|
|
888
|
+
padding: 12px;
|
|
889
|
+
border-bottom: 1px solid var(--line);
|
|
890
|
+
background: #fff;
|
|
891
|
+
font-size: 14px;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
@media (min-width: 768px) {
|
|
895
|
+
.totals .box .rowy {
|
|
896
|
+
padding: 14px;
|
|
897
|
+
font-size: 15px;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
.totals .box .rowy:nth-child(odd) {
|
|
902
|
+
background: #fafbff;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
.totals .box .rowy span:last-child {
|
|
906
|
+
font-family: 'Courier New', monospace;
|
|
907
|
+
font-weight: 600;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
.totals .box .rowy:last-child {
|
|
911
|
+
border-bottom: none;
|
|
912
|
+
background: var(--accent-ink);
|
|
913
|
+
color: #fff;
|
|
914
|
+
font-weight: 800;
|
|
915
|
+
font-size: 16px;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
.totals .box .rowy:last-child span:last-child {
|
|
919
|
+
color: #fff;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
.note {
|
|
923
|
+
margin-top: 32px;
|
|
924
|
+
margin-bottom: 32px;
|
|
925
|
+
border: 2px dashed var(--line);
|
|
926
|
+
border-radius: 14px;
|
|
927
|
+
padding: 20px;
|
|
928
|
+
color: var(--muted);
|
|
929
|
+
background: #fafbff;
|
|
930
|
+
font-size: 13px;
|
|
931
|
+
line-height: 1.8;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
@media (min-width: 768px) {
|
|
935
|
+
.note {
|
|
936
|
+
margin-top: 40px;
|
|
937
|
+
margin-bottom: 40px;
|
|
938
|
+
padding: 24px;
|
|
939
|
+
font-size: 14px;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
.invoice-footer {
|
|
944
|
+
margin-top: 60px;
|
|
945
|
+
padding: 32px 0 0 0;
|
|
946
|
+
border-top: 3px solid var(--line);
|
|
947
|
+
text-align: center;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
.invoice-footer p {
|
|
951
|
+
margin: 0;
|
|
952
|
+
font-size: 13px;
|
|
953
|
+
color: var(--muted);
|
|
954
|
+
font-weight: 600;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
.invoice-footer span {
|
|
958
|
+
color: var(--accent);
|
|
959
|
+
font-weight: 700;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
@media (min-width: 768px) {
|
|
963
|
+
.invoice-footer {
|
|
964
|
+
margin-top: 80px;
|
|
965
|
+
padding: 36px 0 0 0;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
.invoice-footer p {
|
|
969
|
+
font-size: 14px;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
.empty-state {
|
|
974
|
+
text-align: center;
|
|
975
|
+
padding: 80px 40px;
|
|
976
|
+
color: var(--muted);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
.empty-state-icon {
|
|
980
|
+
font-size: 64px;
|
|
981
|
+
margin-bottom: 16px;
|
|
982
|
+
opacity: 0.5;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
.empty-state p {
|
|
986
|
+
font-size: 16px;
|
|
987
|
+
font-weight: 600;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
.validation-error {
|
|
991
|
+
color: var(--danger);
|
|
992
|
+
font-size: 12px;
|
|
993
|
+
margin-top: 4px;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/* --- Print control --- */
|
|
997
|
+
@page {
|
|
998
|
+
size: A4;
|
|
999
|
+
margin: 0;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
@media print {
|
|
1003
|
+
html, body {
|
|
1004
|
+
height: auto;
|
|
1005
|
+
margin: 0;
|
|
1006
|
+
padding: 0;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
body {
|
|
1010
|
+
background: white !important;
|
|
1011
|
+
-webkit-print-color-adjust: exact;
|
|
1012
|
+
print-color-adjust: exact;
|
|
1013
|
+
color-adjust: exact;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
* {
|
|
1017
|
+
-webkit-print-color-adjust: exact !important;
|
|
1018
|
+
print-color-adjust: exact !important;
|
|
1019
|
+
color-adjust: exact !important;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
.wrap {
|
|
1023
|
+
display: block;
|
|
1024
|
+
margin: 0;
|
|
1025
|
+
padding: 0;
|
|
1026
|
+
max-width: 100%;
|
|
1027
|
+
background: white;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
.card.inputs {
|
|
1031
|
+
display: none !important;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
.card.preview {
|
|
1035
|
+
border: none !important;
|
|
1036
|
+
box-shadow: none !important;
|
|
1037
|
+
border-radius: 0 !important;
|
|
1038
|
+
background: white !important;
|
|
1039
|
+
margin: 0;
|
|
1040
|
+
padding: 0;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
.card.preview .bd {
|
|
1044
|
+
padding: 0 !important;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
.sheet {
|
|
1048
|
+
border: none !important;
|
|
1049
|
+
border-radius: 0 !important;
|
|
1050
|
+
box-shadow: none !important;
|
|
1051
|
+
max-width: 100% !important;
|
|
1052
|
+
width: 210mm !important;
|
|
1053
|
+
min-height: 297mm !important;
|
|
1054
|
+
margin: 0 auto !important;
|
|
1055
|
+
padding: 20mm 20mm 15mm 20mm !important;
|
|
1056
|
+
background: white !important;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
.actions, .toolbar {
|
|
1060
|
+
display: none !important;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/* Ensure proper page breaks */
|
|
1064
|
+
.inv-header, .grid, table, .totals {
|
|
1065
|
+
page-break-inside: avoid;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/* Ensure logo prints correctly */
|
|
1069
|
+
.brand-badge svg {
|
|
1070
|
+
color: white !important;
|
|
1071
|
+
stroke: white !important;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/* Footer for print */
|
|
1075
|
+
.invoice-footer {
|
|
1076
|
+
margin-top: 60px !important;
|
|
1077
|
+
padding-top: 32px !important;
|
|
1078
|
+
border-top: 3px solid var(--line) !important;
|
|
1079
|
+
page-break-before: avoid;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/* Hide modal in print */
|
|
1083
|
+
.modal {
|
|
1084
|
+
display: none !important;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/* Modal Styles */
|
|
1089
|
+
.modal {
|
|
1090
|
+
display: none;
|
|
1091
|
+
position: fixed;
|
|
1092
|
+
top: 0;
|
|
1093
|
+
left: 0;
|
|
1094
|
+
width: 100%;
|
|
1095
|
+
height: 100%;
|
|
1096
|
+
background: rgba(0, 0, 0, 0.6);
|
|
1097
|
+
backdrop-filter: blur(4px);
|
|
1098
|
+
z-index: 10000;
|
|
1099
|
+
animation: fadeIn 0.2s ease-out;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
.modal.show {
|
|
1103
|
+
display: flex;
|
|
1104
|
+
align-items: center;
|
|
1105
|
+
justify-content: center;
|
|
1106
|
+
padding: 20px;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
@keyframes fadeIn {
|
|
1110
|
+
from {
|
|
1111
|
+
opacity: 0;
|
|
1112
|
+
}
|
|
1113
|
+
to {
|
|
1114
|
+
opacity: 1;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
@keyframes slideUp {
|
|
1119
|
+
from {
|
|
1120
|
+
transform: translateY(20px);
|
|
1121
|
+
opacity: 0;
|
|
1122
|
+
}
|
|
1123
|
+
to {
|
|
1124
|
+
transform: translateY(0);
|
|
1125
|
+
opacity: 1;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
.modal-content {
|
|
1130
|
+
background: white;
|
|
1131
|
+
border-radius: 20px;
|
|
1132
|
+
max-width: 500px;
|
|
1133
|
+
width: 100%;
|
|
1134
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
1135
|
+
animation: slideUp 0.3s ease-out;
|
|
1136
|
+
overflow: hidden;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
.modal-header {
|
|
1140
|
+
display: flex;
|
|
1141
|
+
justify-content: space-between;
|
|
1142
|
+
align-items: center;
|
|
1143
|
+
padding: 28px 32px;
|
|
1144
|
+
border-bottom: 2px solid var(--line);
|
|
1145
|
+
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
.modal-header h3 {
|
|
1149
|
+
margin: 0;
|
|
1150
|
+
font-size: 22px;
|
|
1151
|
+
font-weight: 800;
|
|
1152
|
+
color: var(--ink);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
.modal-close {
|
|
1156
|
+
background: none;
|
|
1157
|
+
border: none;
|
|
1158
|
+
font-size: 32px;
|
|
1159
|
+
line-height: 1;
|
|
1160
|
+
color: var(--muted);
|
|
1161
|
+
cursor: pointer;
|
|
1162
|
+
padding: 0;
|
|
1163
|
+
width: 32px;
|
|
1164
|
+
height: 32px;
|
|
1165
|
+
display: flex;
|
|
1166
|
+
align-items: center;
|
|
1167
|
+
justify-content: center;
|
|
1168
|
+
border-radius: 8px;
|
|
1169
|
+
transition: all 0.2s;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
.modal-close:hover {
|
|
1173
|
+
background: rgba(0, 0, 0, 0.05);
|
|
1174
|
+
color: var(--ink);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
.modal-body {
|
|
1178
|
+
padding: 32px;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
.modal-description {
|
|
1182
|
+
margin: 0 0 24px 0;
|
|
1183
|
+
color: var(--ink-light);
|
|
1184
|
+
font-size: 15px;
|
|
1185
|
+
line-height: 1.6;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
.modal-input-group {
|
|
1189
|
+
margin-bottom: 16px;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
.modal-input-group label {
|
|
1193
|
+
display: block;
|
|
1194
|
+
margin-bottom: 10px;
|
|
1195
|
+
font-weight: 700;
|
|
1196
|
+
font-size: 13px;
|
|
1197
|
+
color: var(--muted);
|
|
1198
|
+
text-transform: uppercase;
|
|
1199
|
+
letter-spacing: 0.5px;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
.modal-input-group input {
|
|
1203
|
+
width: 100%;
|
|
1204
|
+
padding: 14px 16px;
|
|
1205
|
+
border: 2px solid var(--line);
|
|
1206
|
+
border-radius: 12px;
|
|
1207
|
+
font-size: 16px;
|
|
1208
|
+
font-weight: 500;
|
|
1209
|
+
color: var(--ink);
|
|
1210
|
+
transition: all 0.2s;
|
|
1211
|
+
box-sizing: border-box;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
.modal-input-group input:focus {
|
|
1215
|
+
outline: none;
|
|
1216
|
+
border-color: var(--accent);
|
|
1217
|
+
box-shadow: 0 0 0 4px rgba(30, 41, 59, 0.1);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
.modal-error {
|
|
1221
|
+
padding: 12px 16px;
|
|
1222
|
+
background: #fee2e2;
|
|
1223
|
+
border: 2px solid #ef4444;
|
|
1224
|
+
border-radius: 10px;
|
|
1225
|
+
color: #991b1b;
|
|
1226
|
+
font-size: 14px;
|
|
1227
|
+
font-weight: 600;
|
|
1228
|
+
margin-top: 16px;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
.modal-footer {
|
|
1232
|
+
display: flex;
|
|
1233
|
+
gap: 12px;
|
|
1234
|
+
padding: 24px 32px 32px 32px;
|
|
1235
|
+
justify-content: flex-end;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
.btn-secondary,
|
|
1239
|
+
.btn-primary {
|
|
1240
|
+
padding: 14px 28px;
|
|
1241
|
+
font-size: 15px;
|
|
1242
|
+
font-weight: 700;
|
|
1243
|
+
border-radius: 12px;
|
|
1244
|
+
border: none;
|
|
1245
|
+
cursor: pointer;
|
|
1246
|
+
transition: all 0.2s;
|
|
1247
|
+
display: inline-flex;
|
|
1248
|
+
align-items: center;
|
|
1249
|
+
gap: 8px;
|
|
1250
|
+
min-height: 48px;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
.btn-secondary {
|
|
1254
|
+
background: white;
|
|
1255
|
+
color: var(--ink);
|
|
1256
|
+
border: 2px solid var(--line);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
.btn-secondary:hover {
|
|
1260
|
+
background: var(--line-light);
|
|
1261
|
+
border-color: var(--muted);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
.btn-primary {
|
|
1265
|
+
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
|
1266
|
+
color: white;
|
|
1267
|
+
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
.btn-primary:hover {
|
|
1271
|
+
transform: translateY(-2px);
|
|
1272
|
+
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.5);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
.btn-primary:active {
|
|
1276
|
+
transform: translateY(0);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
.btn-primary:disabled {
|
|
1280
|
+
opacity: 0.6;
|
|
1281
|
+
cursor: not-allowed;
|
|
1282
|
+
transform: none;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
.btn-primary span {
|
|
1286
|
+
font-size: 18px;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
@media (max-width: 640px) {
|
|
1290
|
+
.modal-content {
|
|
1291
|
+
border-radius: 16px;
|
|
1292
|
+
max-width: 100%;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
.modal-header {
|
|
1296
|
+
padding: 20px 24px;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
.modal-header h3 {
|
|
1300
|
+
font-size: 19px;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
.modal-body {
|
|
1304
|
+
padding: 24px;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
.modal-footer {
|
|
1308
|
+
padding: 20px 24px 24px 24px;
|
|
1309
|
+
flex-direction: column-reverse;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
.btn-secondary,
|
|
1313
|
+
.btn-primary {
|
|
1314
|
+
width: 100%;
|
|
1315
|
+
justify-content: center;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
</style>
|
|
1319
|
+
</head>
|
|
1320
|
+
<body>
|
|
1321
|
+
<div class="wrap">
|
|
1322
|
+
<!-- LEFT: Inputs -->
|
|
1323
|
+
<div class="card inputs">
|
|
1324
|
+
<div class="hd">
|
|
1325
|
+
<div>Invoicer</div>
|
|
1326
|
+
<div style="font-weight: 600; font-size: 12px; color: white; opacity: 0.9;">v1.0</div>
|
|
1327
|
+
</div>
|
|
1328
|
+
<div class="bd">
|
|
1329
|
+
<!-- Basic Info -->
|
|
1330
|
+
<h2>Basic Information</h2>
|
|
1331
|
+
<div class="row">
|
|
1332
|
+
<div>
|
|
1333
|
+
<label>Freelancer Name</label>
|
|
1334
|
+
<input id="in_freelancer" placeholder="Your name" value="Milad Ezzzat" />
|
|
1335
|
+
</div>
|
|
1336
|
+
<div>
|
|
1337
|
+
<label>Email</label>
|
|
1338
|
+
<input id="in_email" type="email" placeholder="your@email.com" value="miladezzat.f@gmail.com" />
|
|
1339
|
+
</div>
|
|
1340
|
+
</div>
|
|
1341
|
+
|
|
1342
|
+
<div class="row-full">
|
|
1343
|
+
<label>Freelancer Address</label>
|
|
1344
|
+
<textarea id="in_freelancer_address" placeholder="Your business address (optional)" rows="2"></textarea>
|
|
1345
|
+
</div>
|
|
1346
|
+
|
|
1347
|
+
<div class="row-full">
|
|
1348
|
+
<label>Client Name</label>
|
|
1349
|
+
<input id="in_client" placeholder="Client name" value="Company Name" />
|
|
1350
|
+
</div>
|
|
1351
|
+
|
|
1352
|
+
<div class="row-full">
|
|
1353
|
+
<label>Client Address</label>
|
|
1354
|
+
<textarea id="in_client_address" placeholder="Client address (optional)" rows="2"></textarea>
|
|
1355
|
+
</div>
|
|
1356
|
+
|
|
1357
|
+
<div class="row">
|
|
1358
|
+
<div>
|
|
1359
|
+
<label>Invoice Number</label>
|
|
1360
|
+
<input id="in_number" placeholder="INV-0001" value="0001" />
|
|
1361
|
+
</div>
|
|
1362
|
+
<div>
|
|
1363
|
+
<label>Payment Terms</label>
|
|
1364
|
+
<select id="in_payment_terms">
|
|
1365
|
+
<option value="net30">Net 30</option>
|
|
1366
|
+
<option value="net15">Net 15</option>
|
|
1367
|
+
<option value="due_on_receipt" selected>Due on Receipt</option>
|
|
1368
|
+
</select>
|
|
1369
|
+
</div>
|
|
1370
|
+
</div>
|
|
1371
|
+
|
|
1372
|
+
<div class="row">
|
|
1373
|
+
<div>
|
|
1374
|
+
<label>Invoice Date</label>
|
|
1375
|
+
<input id="in_date" type="date" />
|
|
1376
|
+
</div>
|
|
1377
|
+
<div>
|
|
1378
|
+
<label>Due Date</label>
|
|
1379
|
+
<input id="in_due" type="date" />
|
|
1380
|
+
</div>
|
|
1381
|
+
</div>
|
|
1382
|
+
|
|
1383
|
+
<div class="row">
|
|
1384
|
+
<div>
|
|
1385
|
+
<label>Currency</label>
|
|
1386
|
+
<select id="in_currency">
|
|
1387
|
+
<option value="$" selected>$ (USD)</option>
|
|
1388
|
+
<option value="€">€ (EUR)</option>
|
|
1389
|
+
<option value="£">£ (GBP)</option>
|
|
1390
|
+
<option value="¥">¥ (JPY)</option>
|
|
1391
|
+
</select>
|
|
1392
|
+
</div>
|
|
1393
|
+
<div>
|
|
1394
|
+
<label>Brand Color</label>
|
|
1395
|
+
<input type="color" id="in_color" value="#1e293b" style="height: 44px; cursor: pointer;" />
|
|
1396
|
+
</div>
|
|
1397
|
+
</div>
|
|
1398
|
+
|
|
1399
|
+
<hr class="section-divider" />
|
|
1400
|
+
|
|
1401
|
+
<!-- Line Items -->
|
|
1402
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin: 24px 0 16px;">
|
|
1403
|
+
<h2 style="margin: 0; border: none; padding: 0;">Line Items</h2>
|
|
1404
|
+
<button id="btn_add_item" class="secondary" style="padding: 10px 20px; font-size: 13px; font-weight: 700;">
|
|
1405
|
+
<span style="font-size: 16px; margin-right: 4px;">+</span> Add Item
|
|
1406
|
+
</button>
|
|
1407
|
+
</div>
|
|
1408
|
+
|
|
1409
|
+
<div class="line-items" id="line_items_container">
|
|
1410
|
+
<!-- Line items inserted here -->
|
|
1411
|
+
</div>
|
|
1412
|
+
|
|
1413
|
+
<hr class="section-divider" />
|
|
1414
|
+
|
|
1415
|
+
<!-- Totals Config -->
|
|
1416
|
+
<h2>Totals</h2>
|
|
1417
|
+
<div class="row">
|
|
1418
|
+
<div>
|
|
1419
|
+
<label>Tax (%)</label>
|
|
1420
|
+
<input id="in_tax" type="number" step="0.01" value="0" />
|
|
1421
|
+
</div>
|
|
1422
|
+
<div>
|
|
1423
|
+
<label>Discount (flat)</label>
|
|
1424
|
+
<input id="in_discount" type="number" step="0.01" value="0" />
|
|
1425
|
+
</div>
|
|
1426
|
+
</div>
|
|
1427
|
+
|
|
1428
|
+
<div class="row-full">
|
|
1429
|
+
<label>Footer Notes</label>
|
|
1430
|
+
<textarea id="in_note" placeholder="Optional text shown under totals"></textarea>
|
|
1431
|
+
</div>
|
|
1432
|
+
|
|
1433
|
+
<div class="actions">
|
|
1434
|
+
<button id="btn_update" class="secondary" type="button">
|
|
1435
|
+
<span style="font-size: 16px; margin-right: 6px;">↻</span> Refresh Preview
|
|
1436
|
+
</button>
|
|
1437
|
+
<button id="btn_print" type="button">
|
|
1438
|
+
<span style="font-size: 16px; margin-right: 6px;">⬇</span> Print / PDF
|
|
1439
|
+
</button>
|
|
1440
|
+
<button id="btn_send_email" type="button" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);">
|
|
1441
|
+
<span style="font-size: 16px; margin-right: 6px;">✉</span> Send via Gmail
|
|
1442
|
+
</button>
|
|
1443
|
+
</div>
|
|
1444
|
+
|
|
1445
|
+
<!-- Gmail Connection Status -->
|
|
1446
|
+
<div id="gmail_status" style="margin-top: 16px; padding: 12px 16px; background: #f8fafc; border: 2px solid var(--line); border-radius: 10px; display: flex; align-items: center; justify-content: space-between;">
|
|
1447
|
+
<div style="display: flex; align-items: center; gap: 10px;">
|
|
1448
|
+
<span id="gmail_status_icon" style="font-size: 20px;">⚪</span>
|
|
1449
|
+
<div>
|
|
1450
|
+
<div id="gmail_status_text" style="font-size: 13px; font-weight: 600; color: var(--ink);">Gmail Not Connected</div>
|
|
1451
|
+
<div id="gmail_status_email" style="font-size: 11px; color: var(--muted); margin-top: 2px;"></div>
|
|
1452
|
+
</div>
|
|
1453
|
+
</div>
|
|
1454
|
+
<button id="btn_connect_gmail" type="button" style="padding: 8px 16px; font-size: 13px; min-height: 36px; background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);">
|
|
1455
|
+
<span style="margin-right: 6px;">🔗</span> Connect Gmail
|
|
1456
|
+
</button>
|
|
1457
|
+
</div>
|
|
1458
|
+
|
|
1459
|
+
<div style="margin-top: 12px; padding: 16px; background: linear-gradient(135deg, #dbeafe 0%, #e0e7ff 100%); border-left: 4px solid var(--accent); border-radius: 10px;">
|
|
1460
|
+
<p style="margin: 0 0 8px 0; font-size: 14px; color: var(--ink); font-weight: 700;">
|
|
1461
|
+
📨 How to Send Invoice:
|
|
1462
|
+
</p>
|
|
1463
|
+
<ol style="margin: 0; padding-left: 20px; font-size: 13px; color: var(--ink-light); line-height: 1.8;">
|
|
1464
|
+
<li>Click <strong>"Connect Gmail"</strong> to authorize (one-time)</li>
|
|
1465
|
+
<li>Click <strong>"Send via Gmail"</strong></li>
|
|
1466
|
+
<li>Enter client email in the popup</li>
|
|
1467
|
+
<li>PDF attaches automatically and sends! ✓</li>
|
|
1468
|
+
</ol>
|
|
1469
|
+
</div>
|
|
1470
|
+
</div>
|
|
1471
|
+
</div>
|
|
1472
|
+
|
|
1473
|
+
<!-- RIGHT: Preview -->
|
|
1474
|
+
<div class="card preview">
|
|
1475
|
+
<div class="bd">
|
|
1476
|
+
<div class="sheet" id="preview">
|
|
1477
|
+
<div class="empty-state">
|
|
1478
|
+
<div class="empty-state-icon">📄</div>
|
|
1479
|
+
<p>Loading preview...</p>
|
|
1480
|
+
</div>
|
|
1481
|
+
</div>
|
|
1482
|
+
</div>
|
|
1483
|
+
</div>
|
|
1484
|
+
</div>
|
|
1485
|
+
|
|
1486
|
+
<!-- Email Modal -->
|
|
1487
|
+
<div id="emailModal" class="modal">
|
|
1488
|
+
<div class="modal-content">
|
|
1489
|
+
<div class="modal-header">
|
|
1490
|
+
<h3>📧 Send Invoice via Gmail</h3>
|
|
1491
|
+
<button class="modal-close" id="closeModal">×</button>
|
|
1492
|
+
</div>
|
|
1493
|
+
<div class="modal-body">
|
|
1494
|
+
<p class="modal-description">Enter the client's email address to send the invoice:</p>
|
|
1495
|
+
<div class="modal-input-group">
|
|
1496
|
+
<label for="modal_client_email">Client Email Address</label>
|
|
1497
|
+
<input
|
|
1498
|
+
type="email"
|
|
1499
|
+
id="modal_client_email"
|
|
1500
|
+
placeholder="client@example.com"
|
|
1501
|
+
autocomplete="email"
|
|
1502
|
+
/>
|
|
1503
|
+
</div>
|
|
1504
|
+
<div id="modal_error" class="modal-error" style="display: none;"></div>
|
|
1505
|
+
</div>
|
|
1506
|
+
<div class="modal-footer">
|
|
1507
|
+
<button id="cancelEmailBtn" class="btn-secondary">Cancel</button>
|
|
1508
|
+
<button id="confirmSendBtn" class="btn-primary">
|
|
1509
|
+
<span>✉</span> Send Invoice
|
|
1510
|
+
</button>
|
|
1511
|
+
</div>
|
|
1512
|
+
</div>
|
|
1513
|
+
</div>
|
|
1514
|
+
|
|
1515
|
+
<!-- Templates -->
|
|
1516
|
+
<template id="tpl-invoice">
|
|
1517
|
+
<div>
|
|
1518
|
+
<!-- Header -->
|
|
1519
|
+
<div class="inv-header">
|
|
1520
|
+
<div class="brand">
|
|
1521
|
+
<div class="brand-badge" id="p_badge">
|
|
1522
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" width="40" height="40">
|
|
1523
|
+
<text x="30" y="42" font-family="Arial, sans-serif" font-size="32" font-weight="900" fill="currentColor" text-anchor="middle" letter-spacing="-2">IN</text>
|
|
1524
|
+
</svg>
|
|
1525
|
+
</div>
|
|
1526
|
+
<div class="brand-title">INVOICER</div>
|
|
1527
|
+
</div>
|
|
1528
|
+
<div class="meta">
|
|
1529
|
+
<div class="rowx"><div>Invoice #</div><div id="p_num"></div></div>
|
|
1530
|
+
<div class="rowx"><div>Date</div><div id="p_date"></div></div>
|
|
1531
|
+
<div class="rowx"><div>Due</div><div id="p_due"></div></div>
|
|
1532
|
+
</div>
|
|
1533
|
+
</div>
|
|
1534
|
+
|
|
1535
|
+
<!-- Parties -->
|
|
1536
|
+
<div class="grid">
|
|
1537
|
+
<div class="panel">
|
|
1538
|
+
<div class="title">From</div>
|
|
1539
|
+
<div class="content" id="p_from"></div>
|
|
1540
|
+
</div>
|
|
1541
|
+
<div class="panel">
|
|
1542
|
+
<div class="title">Bill To</div>
|
|
1543
|
+
<div class="content" id="p_to"></div>
|
|
1544
|
+
</div>
|
|
1545
|
+
</div>
|
|
1546
|
+
|
|
1547
|
+
<!-- Items Table -->
|
|
1548
|
+
<div class="table-wrapper">
|
|
1549
|
+
<table>
|
|
1550
|
+
<thead>
|
|
1551
|
+
<tr>
|
|
1552
|
+
<th>Description</th>
|
|
1553
|
+
<th>Period</th>
|
|
1554
|
+
<th class="right">Days</th>
|
|
1555
|
+
<th class="right">Hours</th>
|
|
1556
|
+
<th class="right">Rate</th>
|
|
1557
|
+
<th class="right">Amount</th>
|
|
1558
|
+
</tr>
|
|
1559
|
+
</thead>
|
|
1560
|
+
<tbody id="p_items">
|
|
1561
|
+
<!-- Items inserted here -->
|
|
1562
|
+
</tbody>
|
|
1563
|
+
</table>
|
|
1564
|
+
</div>
|
|
1565
|
+
|
|
1566
|
+
<!-- Totals -->
|
|
1567
|
+
<div class="totals">
|
|
1568
|
+
<div class="box">
|
|
1569
|
+
<div class="rowy"><span>Subtotal</span><span id="p_subtotal" class="currency"></span></div>
|
|
1570
|
+
<div class="rowy" id="p_tax_row" style="display: none;"><span>Tax</span><span id="p_tax" class="currency"></span></div>
|
|
1571
|
+
<div class="rowy" id="p_discount_row" style="display: none;"><span>Discount</span><span id="p_discount" class="currency"></span></div>
|
|
1572
|
+
<div class="rowy"><span>Total Due</span><span id="p_total" class="currency"></span></div>
|
|
1573
|
+
</div>
|
|
1574
|
+
</div>
|
|
1575
|
+
|
|
1576
|
+
<!-- Note -->
|
|
1577
|
+
<div class="note" id="p_note_wrap" style="display: none;"></div>
|
|
1578
|
+
|
|
1579
|
+
<!-- Footer -->
|
|
1580
|
+
<div class="invoice-footer">
|
|
1581
|
+
<p>
|
|
1582
|
+
Powered by <span>Invoicer</span>
|
|
1583
|
+
</p>
|
|
1584
|
+
</div>
|
|
1585
|
+
</div>
|
|
1586
|
+
</template>
|
|
1587
|
+
|
|
1588
|
+
<template id="tpl-line-item">
|
|
1589
|
+
<div class="line-item">
|
|
1590
|
+
<div class="line-item-header">
|
|
1591
|
+
<div class="line-item-title" data-index="">Item <span class="badge" data-index=""></span></div>
|
|
1592
|
+
<div class="line-item-actions">
|
|
1593
|
+
<button class="secondary" data-action="remove" style="padding: 6px 10px; font-size: 12px;">Delete</button>
|
|
1594
|
+
</div>
|
|
1595
|
+
</div>
|
|
1596
|
+
<div class="row-full">
|
|
1597
|
+
<label>Description</label>
|
|
1598
|
+
<input placeholder="e.g., Web Development Services" data-field="description" />
|
|
1599
|
+
</div>
|
|
1600
|
+
<div class="row">
|
|
1601
|
+
<div>
|
|
1602
|
+
<label>Period From</label>
|
|
1603
|
+
<input type="date" data-field="periodFrom" />
|
|
1604
|
+
</div>
|
|
1605
|
+
<div>
|
|
1606
|
+
<label>Period To</label>
|
|
1607
|
+
<input type="date" data-field="periodTo" />
|
|
1608
|
+
</div>
|
|
1609
|
+
</div>
|
|
1610
|
+
<div class="row-3">
|
|
1611
|
+
<div>
|
|
1612
|
+
<label>Days (optional)</label>
|
|
1613
|
+
<input type="number" placeholder="15" step="1" value="" data-field="days" />
|
|
1614
|
+
</div>
|
|
1615
|
+
<div>
|
|
1616
|
+
<label>Hours/Day (optional)</label>
|
|
1617
|
+
<input type="number" placeholder="8" step="0.5" value="" data-field="hoursPerDay" />
|
|
1618
|
+
</div>
|
|
1619
|
+
<div>
|
|
1620
|
+
<label>Total Hours</label>
|
|
1621
|
+
<input type="number" placeholder="120" step="0.01" value="" data-field="quantity" />
|
|
1622
|
+
</div>
|
|
1623
|
+
</div>
|
|
1624
|
+
<div class="row">
|
|
1625
|
+
<div>
|
|
1626
|
+
<label>Hourly Rate</label>
|
|
1627
|
+
<input type="number" placeholder="50.00" step="0.01" value="50.00" data-field="rate" />
|
|
1628
|
+
</div>
|
|
1629
|
+
<div>
|
|
1630
|
+
<label>Amount (auto)</label>
|
|
1631
|
+
<input type="number" step="0.01" data-field="amount" disabled />
|
|
1632
|
+
</div>
|
|
1633
|
+
</div>
|
|
1634
|
+
</div>
|
|
1635
|
+
</template>
|
|
1636
|
+
|
|
1637
|
+
<script>
|
|
1638
|
+
// ============================================================================
|
|
1639
|
+
// GMAIL API CONFIGURATION
|
|
1640
|
+
// ============================================================================
|
|
1641
|
+
const GMAIL_CONFIG = {
|
|
1642
|
+
// OAuth 2.0 Client ID from Google Cloud Console
|
|
1643
|
+
// Project: my-project-82943-1733742444743
|
|
1644
|
+
CLIENT_ID: '813092907872-4tqnmopeusnuc0re17rc4o423f1hst7f.apps.googleusercontent.com',
|
|
1645
|
+
DISCOVERY_DOCS: ['https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest'],
|
|
1646
|
+
SCOPES: 'https://www.googleapis.com/auth/gmail.send'
|
|
1647
|
+
};
|
|
1648
|
+
|
|
1649
|
+
// Gmail OAuth State
|
|
1650
|
+
let gmailAuthorized = false;
|
|
1651
|
+
let gmailAccessToken = null;
|
|
1652
|
+
let gmailUserEmail = null;
|
|
1653
|
+
|
|
1654
|
+
// ============================================================================
|
|
1655
|
+
// CONSTANTS
|
|
1656
|
+
// ============================================================================
|
|
1657
|
+
const CONFIG = {
|
|
1658
|
+
SELECTORS: {
|
|
1659
|
+
freelancer: '#in_freelancer',
|
|
1660
|
+
email: '#in_email',
|
|
1661
|
+
freelancerAddress: '#in_freelancer_address',
|
|
1662
|
+
client: '#in_client',
|
|
1663
|
+
clientAddress: '#in_client_address',
|
|
1664
|
+
clientEmail: '#in_client_email',
|
|
1665
|
+
number: '#in_number',
|
|
1666
|
+
date: '#in_date',
|
|
1667
|
+
due: '#in_due',
|
|
1668
|
+
currency: '#in_currency',
|
|
1669
|
+
color: '#in_color',
|
|
1670
|
+
tax: '#in_tax',
|
|
1671
|
+
discount: '#in_discount',
|
|
1672
|
+
note: '#in_note',
|
|
1673
|
+
lineItemsContainer: '#line_items_container',
|
|
1674
|
+
preview: '#preview',
|
|
1675
|
+
updateBtn: '#btn_update',
|
|
1676
|
+
printBtn: '#btn_print',
|
|
1677
|
+
printTopBtn: '#btn_print_top',
|
|
1678
|
+
addItemBtn: '#btn_add_item',
|
|
1679
|
+
sendEmailBtn: '#btn_send_email',
|
|
1680
|
+
connectGmailBtn: '#btn_connect_gmail',
|
|
1681
|
+
gmailStatusIcon: '#gmail_status_icon',
|
|
1682
|
+
gmailStatusText: '#gmail_status_text',
|
|
1683
|
+
gmailStatusEmail: '#gmail_status_email'
|
|
1684
|
+
},
|
|
1685
|
+
DEFAULTS: {
|
|
1686
|
+
lineItem: { description: '', periodFrom: '', periodTo: '', days: '', hoursPerDay: '', quantity: '', rate: 100, amount: 0 }
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
|
|
1690
|
+
// ============================================================================
|
|
1691
|
+
// STATE MANAGEMENT
|
|
1692
|
+
// ============================================================================
|
|
1693
|
+
class InvoiceState {
|
|
1694
|
+
constructor() {
|
|
1695
|
+
this.lineItems = [{ ...CONFIG.DEFAULTS.lineItem }];
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
addLineItem() {
|
|
1699
|
+
this.lineItems.push({ ...CONFIG.DEFAULTS.lineItem });
|
|
1700
|
+
return this.lineItems.length - 1;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
removeLineItem(index) {
|
|
1704
|
+
if (this.lineItems.length > 1) {
|
|
1705
|
+
this.lineItems.splice(index, 1);
|
|
1706
|
+
return true;
|
|
1707
|
+
}
|
|
1708
|
+
return false;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
getLineItem(index) {
|
|
1712
|
+
return this.lineItems[index];
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
updateLineItem(index, field, value) {
|
|
1716
|
+
if (this.lineItems[index]) {
|
|
1717
|
+
this.lineItems[index][field] = value;
|
|
1718
|
+
|
|
1719
|
+
// Auto-calculate total hours from days × hoursPerDay
|
|
1720
|
+
if (field === 'days' || field === 'hoursPerDay') {
|
|
1721
|
+
const days = parseFloat(this.lineItems[index].days) || 0;
|
|
1722
|
+
const hoursPerDay = parseFloat(this.lineItems[index].hoursPerDay) || 0;
|
|
1723
|
+
if (days > 0 && hoursPerDay > 0) {
|
|
1724
|
+
this.lineItems[index].quantity = days * hoursPerDay;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// Calculate amount from quantity × rate
|
|
1729
|
+
if (field === 'quantity' || field === 'rate' || field === 'days' || field === 'hoursPerDay') {
|
|
1730
|
+
const quantity = parseFloat(this.lineItems[index].quantity) || 0;
|
|
1731
|
+
const rate = parseFloat(this.lineItems[index].rate) || 0;
|
|
1732
|
+
this.lineItems[index].amount = quantity * rate;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
getSubtotal() {
|
|
1738
|
+
return this.lineItems.reduce((sum, item) => sum + (item.quantity * item.rate), 0);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// ============================================================================
|
|
1743
|
+
// UTILITIES
|
|
1744
|
+
// ============================================================================
|
|
1745
|
+
const Utils = {
|
|
1746
|
+
$(selector) {
|
|
1747
|
+
return document.querySelector(selector);
|
|
1748
|
+
},
|
|
1749
|
+
|
|
1750
|
+
formatMoney(amount, currency) {
|
|
1751
|
+
const num = Number(amount || 0);
|
|
1752
|
+
return `${currency}${num.toLocaleString(undefined, {
|
|
1753
|
+
minimumFractionDigits: 2,
|
|
1754
|
+
maximumFractionDigits: 2
|
|
1755
|
+
})}`;
|
|
1756
|
+
},
|
|
1757
|
+
|
|
1758
|
+
shadeColor(hex, amount) {
|
|
1759
|
+
hex = hex.replace('#', '');
|
|
1760
|
+
if (hex.length === 3) {
|
|
1761
|
+
hex = hex.split('').map(c => c + c).join('');
|
|
1762
|
+
}
|
|
1763
|
+
const num = parseInt(hex, 16);
|
|
1764
|
+
let r = (num >> 16) + amount;
|
|
1765
|
+
let g = (num >> 8 & 0x00FF) + amount;
|
|
1766
|
+
let b = (num & 0x0000FF) + amount;
|
|
1767
|
+
|
|
1768
|
+
r = Math.max(0, Math.min(255, r));
|
|
1769
|
+
g = Math.max(0, Math.min(255, g));
|
|
1770
|
+
b = Math.max(0, Math.min(255, b));
|
|
1771
|
+
|
|
1772
|
+
return `#${(r << 16 | g << 8 | b).toString(16).padStart(6, '0')}`;
|
|
1773
|
+
},
|
|
1774
|
+
|
|
1775
|
+
setAccentColor(hex) {
|
|
1776
|
+
document.documentElement.style.setProperty('--accent', hex);
|
|
1777
|
+
const darkHex = Utils.shadeColor(hex, -35);
|
|
1778
|
+
document.documentElement.style.setProperty('--accent-ink', darkHex);
|
|
1779
|
+
|
|
1780
|
+
const badge = Utils.$('p_badge');
|
|
1781
|
+
if (badge) badge.style.background = hex;
|
|
1782
|
+
},
|
|
1783
|
+
|
|
1784
|
+
getTodayISO() {
|
|
1785
|
+
return new Date().toISOString().slice(0, 10);
|
|
1786
|
+
}
|
|
1787
|
+
};
|
|
1788
|
+
|
|
1789
|
+
// ============================================================================
|
|
1790
|
+
// INVOICE BUILDER
|
|
1791
|
+
// ============================================================================
|
|
1792
|
+
class InvoiceBuilder {
|
|
1793
|
+
constructor(state) {
|
|
1794
|
+
this.state = state;
|
|
1795
|
+
this.buildPreview = this.buildPreview.bind(this);
|
|
1796
|
+
this.setupEventListeners();
|
|
1797
|
+
this.renderLineItemsUI();
|
|
1798
|
+
this.initializeDefaults();
|
|
1799
|
+
this.buildPreview();
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
initializeDefaults() {
|
|
1803
|
+
const today = Utils.getTodayISO();
|
|
1804
|
+
Utils.$(CONFIG.SELECTORS.date).value = today;
|
|
1805
|
+
Utils.$(CONFIG.SELECTORS.due).value = today;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
setupEventListeners() {
|
|
1809
|
+
// Line item management
|
|
1810
|
+
const addBtn = Utils.$(CONFIG.SELECTORS.addItemBtn);
|
|
1811
|
+
if (addBtn) addBtn.addEventListener('click', () => this.addLineItem());
|
|
1812
|
+
|
|
1813
|
+
// Form inputs
|
|
1814
|
+
[
|
|
1815
|
+
CONFIG.SELECTORS.freelancer,
|
|
1816
|
+
CONFIG.SELECTORS.email,
|
|
1817
|
+
CONFIG.SELECTORS.client,
|
|
1818
|
+
CONFIG.SELECTORS.number,
|
|
1819
|
+
CONFIG.SELECTORS.date,
|
|
1820
|
+
CONFIG.SELECTORS.due,
|
|
1821
|
+
CONFIG.SELECTORS.currency,
|
|
1822
|
+
CONFIG.SELECTORS.color,
|
|
1823
|
+
CONFIG.SELECTORS.tax,
|
|
1824
|
+
CONFIG.SELECTORS.discount,
|
|
1825
|
+
CONFIG.SELECTORS.note
|
|
1826
|
+
].forEach(selector => {
|
|
1827
|
+
const el = Utils.$(selector);
|
|
1828
|
+
if (el) {
|
|
1829
|
+
el.addEventListener('change', this.buildPreview);
|
|
1830
|
+
el.addEventListener('input', this.buildPreview);
|
|
1831
|
+
}
|
|
1832
|
+
});
|
|
1833
|
+
|
|
1834
|
+
// Buttons
|
|
1835
|
+
const updateBtn = Utils.$(CONFIG.SELECTORS.updateBtn);
|
|
1836
|
+
if (updateBtn) updateBtn.addEventListener('click', this.buildPreview);
|
|
1837
|
+
|
|
1838
|
+
const printBtn = Utils.$(CONFIG.SELECTORS.printBtn);
|
|
1839
|
+
if (printBtn) printBtn.addEventListener('click', () => this.print());
|
|
1840
|
+
|
|
1841
|
+
const printTopBtn = Utils.$(CONFIG.SELECTORS.printTopBtn);
|
|
1842
|
+
if (printTopBtn) printTopBtn.addEventListener('click', () => this.print());
|
|
1843
|
+
|
|
1844
|
+
const sendEmailBtn = Utils.$(CONFIG.SELECTORS.sendEmailBtn);
|
|
1845
|
+
if (sendEmailBtn) sendEmailBtn.addEventListener('click', () => this.showEmailModal());
|
|
1846
|
+
|
|
1847
|
+
const connectGmailBtn = Utils.$(CONFIG.SELECTORS.connectGmailBtn);
|
|
1848
|
+
if (connectGmailBtn) connectGmailBtn.addEventListener('click', () => this.connectGmail());
|
|
1849
|
+
|
|
1850
|
+
// Modal event listeners
|
|
1851
|
+
const closeModalBtn = document.getElementById('closeModal');
|
|
1852
|
+
if (closeModalBtn) closeModalBtn.addEventListener('click', () => this.closeEmailModal());
|
|
1853
|
+
|
|
1854
|
+
const cancelEmailBtn = document.getElementById('cancelEmailBtn');
|
|
1855
|
+
if (cancelEmailBtn) cancelEmailBtn.addEventListener('click', () => this.closeEmailModal());
|
|
1856
|
+
|
|
1857
|
+
const confirmSendBtn = document.getElementById('confirmSendBtn');
|
|
1858
|
+
if (confirmSendBtn) confirmSendBtn.addEventListener('click', () => this.confirmSendEmail());
|
|
1859
|
+
|
|
1860
|
+
// Close modal on outside click
|
|
1861
|
+
const emailModal = document.getElementById('emailModal');
|
|
1862
|
+
if (emailModal) {
|
|
1863
|
+
emailModal.addEventListener('click', (e) => {
|
|
1864
|
+
if (e.target === emailModal) this.closeEmailModal();
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// Submit on Enter key in email input
|
|
1869
|
+
const modalEmailInput = document.getElementById('modal_client_email');
|
|
1870
|
+
if (modalEmailInput) {
|
|
1871
|
+
modalEmailInput.addEventListener('keypress', (e) => {
|
|
1872
|
+
if (e.key === 'Enter') this.confirmSendEmail();
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// Delegate line item events
|
|
1877
|
+
const container = Utils.$(CONFIG.SELECTORS.lineItemsContainer);
|
|
1878
|
+
if (container) {
|
|
1879
|
+
container.addEventListener('input', (e) => {
|
|
1880
|
+
this.handleLineItemInput(e);
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
container.addEventListener('click', (e) => {
|
|
1884
|
+
if (e.target.dataset.action === 'remove') {
|
|
1885
|
+
const itemEl = e.target.closest('.line-item');
|
|
1886
|
+
const index = Array.from(container.children).indexOf(itemEl);
|
|
1887
|
+
this.removeLineItem(index);
|
|
1888
|
+
}
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
handleLineItemInput(event) {
|
|
1894
|
+
const input = event.target;
|
|
1895
|
+
if (!input.dataset.field) return;
|
|
1896
|
+
|
|
1897
|
+
const itemEl = input.closest('.line-item');
|
|
1898
|
+
const index = Array.from(Utils.$(CONFIG.SELECTORS.lineItemsContainer).children).indexOf(itemEl);
|
|
1899
|
+
const field = input.dataset.field;
|
|
1900
|
+
let value = input.value;
|
|
1901
|
+
|
|
1902
|
+
if (field === 'quantity' || field === 'rate' || field === 'days' || field === 'hoursPerDay') {
|
|
1903
|
+
value = parseFloat(value) || 0;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
this.state.updateLineItem(index, field, value);
|
|
1907
|
+
|
|
1908
|
+
// Update calculated fields in UI
|
|
1909
|
+
const item = this.state.getLineItem(index);
|
|
1910
|
+
const qtyInput = itemEl.querySelector('[data-field="quantity"]');
|
|
1911
|
+
if (qtyInput && item.quantity) qtyInput.value = item.quantity;
|
|
1912
|
+
|
|
1913
|
+
const amountInput = itemEl.querySelector('[data-field="amount"]');
|
|
1914
|
+
if (amountInput) amountInput.value = item.amount;
|
|
1915
|
+
|
|
1916
|
+
this.buildPreview();
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
addLineItem() {
|
|
1920
|
+
const index = this.state.addLineItem();
|
|
1921
|
+
this.renderLineItemsUI();
|
|
1922
|
+
this.buildPreview();
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
removeLineItem(index) {
|
|
1926
|
+
if (this.state.removeLineItem(index)) {
|
|
1927
|
+
this.renderLineItemsUI();
|
|
1928
|
+
this.buildPreview();
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
renderLineItemsUI() {
|
|
1933
|
+
try {
|
|
1934
|
+
const container = Utils.$(CONFIG.SELECTORS.lineItemsContainer);
|
|
1935
|
+
if (!container) return;
|
|
1936
|
+
|
|
1937
|
+
container.innerHTML = '';
|
|
1938
|
+
|
|
1939
|
+
this.state.lineItems.forEach((item, index) => {
|
|
1940
|
+
const template = document.getElementById('tpl-line-item');
|
|
1941
|
+
if (!template) {
|
|
1942
|
+
console.error('Line item template not found');
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
const clone = document.importNode(template.content, true);
|
|
1947
|
+
|
|
1948
|
+
// Set badge index (only the span, not the parent div)
|
|
1949
|
+
const badge = clone.querySelector('.badge[data-index]');
|
|
1950
|
+
if (badge) badge.textContent = index + 1;
|
|
1951
|
+
|
|
1952
|
+
// Set values - with safe checks
|
|
1953
|
+
const descInput = clone.querySelector('[data-field="description"]');
|
|
1954
|
+
if (descInput) descInput.value = item.description;
|
|
1955
|
+
|
|
1956
|
+
const periodFromInput = clone.querySelector('[data-field="periodFrom"]');
|
|
1957
|
+
if (periodFromInput) periodFromInput.value = item.periodFrom;
|
|
1958
|
+
|
|
1959
|
+
const periodToInput = clone.querySelector('[data-field="periodTo"]');
|
|
1960
|
+
if (periodToInput) periodToInput.value = item.periodTo;
|
|
1961
|
+
|
|
1962
|
+
const daysInput = clone.querySelector('[data-field="days"]');
|
|
1963
|
+
if (daysInput) daysInput.value = item.days;
|
|
1964
|
+
|
|
1965
|
+
const hoursPerDayInput = clone.querySelector('[data-field="hoursPerDay"]');
|
|
1966
|
+
if (hoursPerDayInput) hoursPerDayInput.value = item.hoursPerDay;
|
|
1967
|
+
|
|
1968
|
+
const qtyInput = clone.querySelector('[data-field="quantity"]');
|
|
1969
|
+
if (qtyInput) qtyInput.value = item.quantity;
|
|
1970
|
+
|
|
1971
|
+
const rateInput = clone.querySelector('[data-field="rate"]');
|
|
1972
|
+
if (rateInput) rateInput.value = item.rate;
|
|
1973
|
+
|
|
1974
|
+
const amountInput = clone.querySelector('[data-field="amount"]');
|
|
1975
|
+
if (amountInput) amountInput.value = item.amount;
|
|
1976
|
+
|
|
1977
|
+
container.appendChild(clone);
|
|
1978
|
+
});
|
|
1979
|
+
} catch (error) {
|
|
1980
|
+
console.error('Error rendering line items:', error);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
buildPreview() {
|
|
1985
|
+
try {
|
|
1986
|
+
Utils.setAccentColor(Utils.$(CONFIG.SELECTORS.color).value);
|
|
1987
|
+
|
|
1988
|
+
const currency = Utils.$(CONFIG.SELECTORS.currency).value;
|
|
1989
|
+
const subtotal = this.state.getSubtotal();
|
|
1990
|
+
const taxPct = parseFloat(Utils.$(CONFIG.SELECTORS.tax).value || 0);
|
|
1991
|
+
const tax = subtotal * (taxPct / 100);
|
|
1992
|
+
const discount = parseFloat(Utils.$(CONFIG.SELECTORS.discount).value || 0);
|
|
1993
|
+
const total = Math.max(0, subtotal + tax - discount);
|
|
1994
|
+
|
|
1995
|
+
const template = document.getElementById('tpl-invoice');
|
|
1996
|
+
if (!template) {
|
|
1997
|
+
console.error('Invoice template not found');
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
const invoice = document.importNode(template.content, true);
|
|
2002
|
+
|
|
2003
|
+
// Header
|
|
2004
|
+
const pNum = invoice.getElementById('p_num');
|
|
2005
|
+
if (pNum) pNum.textContent = Utils.$(CONFIG.SELECTORS.number).value || 'INV-0001';
|
|
2006
|
+
|
|
2007
|
+
const pDate = invoice.getElementById('p_date');
|
|
2008
|
+
if (pDate) pDate.textContent = Utils.$(CONFIG.SELECTORS.date).value || Utils.getTodayISO();
|
|
2009
|
+
|
|
2010
|
+
const pDue = invoice.getElementById('p_due');
|
|
2011
|
+
if (pDue) pDue.textContent = Utils.$(CONFIG.SELECTORS.due).value || Utils.getTodayISO();
|
|
2012
|
+
|
|
2013
|
+
// Parties
|
|
2014
|
+
const pFrom = invoice.getElementById('p_from');
|
|
2015
|
+
if (pFrom) {
|
|
2016
|
+
const freelancerName = Utils.$(CONFIG.SELECTORS.freelancer).value;
|
|
2017
|
+
const freelancerEmail = Utils.$(CONFIG.SELECTORS.email).value;
|
|
2018
|
+
const freelancerAddress = Utils.$(CONFIG.SELECTORS.freelancerAddress).value;
|
|
2019
|
+
|
|
2020
|
+
let fromHTML = `${freelancerName}<br/><span class="muted">${freelancerEmail}</span>`;
|
|
2021
|
+
if (freelancerAddress && freelancerAddress.trim()) {
|
|
2022
|
+
fromHTML += `<br/><span class="muted" style="font-size: 13px; line-height: 1.6;">${freelancerAddress.replace(/\n/g, '<br/>')}</span>`;
|
|
2023
|
+
}
|
|
2024
|
+
pFrom.innerHTML = fromHTML;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
const pTo = invoice.getElementById('p_to');
|
|
2028
|
+
if (pTo) {
|
|
2029
|
+
const clientName = Utils.$(CONFIG.SELECTORS.client).value || 'Client Name';
|
|
2030
|
+
const clientAddress = Utils.$(CONFIG.SELECTORS.clientAddress).value;
|
|
2031
|
+
|
|
2032
|
+
let toHTML = clientName;
|
|
2033
|
+
if (clientAddress && clientAddress.trim()) {
|
|
2034
|
+
toHTML += `<br/><span class="muted" style="font-size: 13px; line-height: 1.6;">${clientAddress.replace(/\n/g, '<br/>')}</span>`;
|
|
2035
|
+
}
|
|
2036
|
+
pTo.innerHTML = toHTML;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// Line items table
|
|
2040
|
+
const tbody = invoice.getElementById('p_items');
|
|
2041
|
+
if (tbody) {
|
|
2042
|
+
this.state.lineItems.forEach(item => {
|
|
2043
|
+
const tr = document.createElement('tr');
|
|
2044
|
+
const days = item.days ? item.days : '—';
|
|
2045
|
+
const hours = item.quantity ? item.quantity : '—';
|
|
2046
|
+
|
|
2047
|
+
// Format period
|
|
2048
|
+
let period = '—';
|
|
2049
|
+
if (item.periodFrom && item.periodTo) {
|
|
2050
|
+
period = `${item.periodFrom} to ${item.periodTo}`;
|
|
2051
|
+
} else if (item.periodFrom) {
|
|
2052
|
+
period = `From ${item.periodFrom}`;
|
|
2053
|
+
} else if (item.periodTo) {
|
|
2054
|
+
period = `Until ${item.periodTo}`;
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
tr.innerHTML = `
|
|
2058
|
+
<td>${item.description || '—'}</td>
|
|
2059
|
+
<td style="white-space: nowrap;">${period}</td>
|
|
2060
|
+
<td class="right">${days}</td>
|
|
2061
|
+
<td class="right">${hours}</td>
|
|
2062
|
+
<td class="right">${Utils.formatMoney(item.rate, currency)}</td>
|
|
2063
|
+
<td class="right currency">${Utils.formatMoney(item.amount, currency)}</td>
|
|
2064
|
+
`;
|
|
2065
|
+
tbody.appendChild(tr);
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
// Totals
|
|
2070
|
+
const pSubtotal = invoice.getElementById('p_subtotal');
|
|
2071
|
+
if (pSubtotal) pSubtotal.textContent = Utils.formatMoney(subtotal, currency);
|
|
2072
|
+
|
|
2073
|
+
const taxRow = invoice.getElementById('p_tax_row');
|
|
2074
|
+
const pTax = invoice.getElementById('p_tax');
|
|
2075
|
+
if (taxRow && pTax) {
|
|
2076
|
+
if (taxPct > 0 || tax > 0) {
|
|
2077
|
+
taxRow.style.display = 'flex';
|
|
2078
|
+
pTax.textContent = Utils.formatMoney(tax, currency);
|
|
2079
|
+
// Update the label to show percentage
|
|
2080
|
+
const taxLabel = taxRow.querySelector('span:first-child');
|
|
2081
|
+
if (taxLabel) taxLabel.textContent = `Tax (${taxPct}%)`;
|
|
2082
|
+
} else {
|
|
2083
|
+
taxRow.style.display = 'none';
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
const discountRow = invoice.getElementById('p_discount_row');
|
|
2088
|
+
const pDiscount = invoice.getElementById('p_discount');
|
|
2089
|
+
if (discountRow && pDiscount) {
|
|
2090
|
+
if (discount > 0) {
|
|
2091
|
+
discountRow.style.display = 'flex';
|
|
2092
|
+
pDiscount.textContent = `-${Utils.formatMoney(discount, currency)}`;
|
|
2093
|
+
} else {
|
|
2094
|
+
discountRow.style.display = 'none';
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
const pTotal = invoice.getElementById('p_total');
|
|
2099
|
+
if (pTotal) pTotal.textContent = Utils.formatMoney(total, currency);
|
|
2100
|
+
|
|
2101
|
+
// Notes
|
|
2102
|
+
const noteText = Utils.$(CONFIG.SELECTORS.note).value.trim();
|
|
2103
|
+
const noteWrap = invoice.getElementById('p_note_wrap');
|
|
2104
|
+
if (noteWrap) {
|
|
2105
|
+
if (noteText) {
|
|
2106
|
+
noteWrap.style.display = 'block';
|
|
2107
|
+
noteWrap.textContent = noteText;
|
|
2108
|
+
} else {
|
|
2109
|
+
noteWrap.style.display = 'none';
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// Render
|
|
2114
|
+
const previewEl = Utils.$(CONFIG.SELECTORS.preview);
|
|
2115
|
+
if (previewEl) {
|
|
2116
|
+
previewEl.innerHTML = '';
|
|
2117
|
+
previewEl.appendChild(invoice);
|
|
2118
|
+
}
|
|
2119
|
+
} catch (error) {
|
|
2120
|
+
console.error('Error building preview:', error);
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
print() {
|
|
2125
|
+
this.buildPreview();
|
|
2126
|
+
this.updateDocumentTitle();
|
|
2127
|
+
setTimeout(() => window.print(), 30);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
showEmailModal() {
|
|
2131
|
+
const modal = document.getElementById('emailModal');
|
|
2132
|
+
const emailInput = document.getElementById('modal_client_email');
|
|
2133
|
+
const errorDiv = document.getElementById('modal_error');
|
|
2134
|
+
|
|
2135
|
+
// Clear previous input and errors
|
|
2136
|
+
if (emailInput) emailInput.value = '';
|
|
2137
|
+
if (errorDiv) errorDiv.style.display = 'none';
|
|
2138
|
+
|
|
2139
|
+
// Show modal
|
|
2140
|
+
if (modal) {
|
|
2141
|
+
modal.classList.add('show');
|
|
2142
|
+
// Focus on input after animation
|
|
2143
|
+
setTimeout(() => {
|
|
2144
|
+
if (emailInput) emailInput.focus();
|
|
2145
|
+
}, 100);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// Prevent body scroll
|
|
2149
|
+
document.body.style.overflow = 'hidden';
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
closeEmailModal() {
|
|
2153
|
+
const modal = document.getElementById('emailModal');
|
|
2154
|
+
if (modal) {
|
|
2155
|
+
modal.classList.remove('show');
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// Restore body scroll
|
|
2159
|
+
document.body.style.overflow = '';
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
validateEmail(email) {
|
|
2163
|
+
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
2164
|
+
return re.test(email);
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
showModalError(message) {
|
|
2168
|
+
const errorDiv = document.getElementById('modal_error');
|
|
2169
|
+
if (errorDiv) {
|
|
2170
|
+
errorDiv.textContent = message;
|
|
2171
|
+
errorDiv.style.display = 'block';
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
hideModalError() {
|
|
2176
|
+
const errorDiv = document.getElementById('modal_error');
|
|
2177
|
+
if (errorDiv) {
|
|
2178
|
+
errorDiv.style.display = 'none';
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
confirmSendEmail() {
|
|
2183
|
+
const emailInput = document.getElementById('modal_client_email');
|
|
2184
|
+
const email = emailInput ? emailInput.value.trim() : '';
|
|
2185
|
+
|
|
2186
|
+
// Hide previous errors
|
|
2187
|
+
this.hideModalError();
|
|
2188
|
+
|
|
2189
|
+
// Validate email
|
|
2190
|
+
if (!email) {
|
|
2191
|
+
this.showModalError('Please enter an email address');
|
|
2192
|
+
if (emailInput) emailInput.focus();
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
if (!this.validateEmail(email)) {
|
|
2197
|
+
this.showModalError('Please enter a valid email address');
|
|
2198
|
+
if (emailInput) emailInput.focus();
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// Close modal and send email
|
|
2203
|
+
this.closeEmailModal();
|
|
2204
|
+
this.sendEmail(email);
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
connectGmail() {
|
|
2208
|
+
if (!GMAIL_CONFIG.CLIENT_ID || GMAIL_CONFIG.CLIENT_ID === 'YOUR_CLIENT_ID_HERE.apps.googleusercontent.com') {
|
|
2209
|
+
alert('⚠️ Gmail API Not Configured\n\nTo enable direct PDF email attachment:\n\n1. Go to https://console.cloud.google.com\n2. Create a project and enable Gmail API\n3. Create OAuth 2.0 Client ID\n4. Replace CLIENT_ID in the code\n\nFor now, I\'ll use the simple method (PDF downloads, you attach manually).');
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
try {
|
|
2214
|
+
// Initialize Google Identity Services
|
|
2215
|
+
google.accounts.oauth2.initTokenClient({
|
|
2216
|
+
client_id: GMAIL_CONFIG.CLIENT_ID,
|
|
2217
|
+
scope: GMAIL_CONFIG.SCOPES,
|
|
2218
|
+
callback: (response) => {
|
|
2219
|
+
if (response.access_token) {
|
|
2220
|
+
gmailAccessToken = response.access_token;
|
|
2221
|
+
gmailAuthorized = true;
|
|
2222
|
+
|
|
2223
|
+
// Get user email
|
|
2224
|
+
this.getGmailProfile();
|
|
2225
|
+
}
|
|
2226
|
+
},
|
|
2227
|
+
}).requestAccessToken();
|
|
2228
|
+
} catch (error) {
|
|
2229
|
+
console.error('Gmail auth error:', error);
|
|
2230
|
+
alert('Gmail connection failed. Check console for details.');
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
async getGmailProfile() {
|
|
2235
|
+
try {
|
|
2236
|
+
const response = await fetch('https://www.googleapis.com/gmail/v1/users/me/profile', {
|
|
2237
|
+
headers: {
|
|
2238
|
+
'Authorization': `Bearer ${gmailAccessToken}`
|
|
2239
|
+
}
|
|
2240
|
+
});
|
|
2241
|
+
const data = await response.json();
|
|
2242
|
+
gmailUserEmail = data.emailAddress;
|
|
2243
|
+
this.updateGmailStatus(true);
|
|
2244
|
+
} catch (error) {
|
|
2245
|
+
console.error('Error getting Gmail profile:', error);
|
|
2246
|
+
gmailAuthorized = false;
|
|
2247
|
+
this.updateGmailStatus(false);
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
updateGmailStatus(connected) {
|
|
2252
|
+
const icon = Utils.$(CONFIG.SELECTORS.gmailStatusIcon);
|
|
2253
|
+
const text = Utils.$(CONFIG.SELECTORS.gmailStatusText);
|
|
2254
|
+
const email = Utils.$(CONFIG.SELECTORS.gmailStatusEmail);
|
|
2255
|
+
const btn = Utils.$(CONFIG.SELECTORS.connectGmailBtn);
|
|
2256
|
+
|
|
2257
|
+
if (connected && gmailUserEmail) {
|
|
2258
|
+
if (icon) icon.textContent = '✅';
|
|
2259
|
+
if (text) text.textContent = 'Gmail Connected';
|
|
2260
|
+
if (email) email.textContent = gmailUserEmail;
|
|
2261
|
+
if (btn) {
|
|
2262
|
+
btn.innerHTML = '<span style="margin-right: 6px;">🔄</span> Reconnect';
|
|
2263
|
+
}
|
|
2264
|
+
} else {
|
|
2265
|
+
if (icon) icon.textContent = '⚪';
|
|
2266
|
+
if (text) text.textContent = 'Gmail Not Connected';
|
|
2267
|
+
if (email) email.textContent = '';
|
|
2268
|
+
if (btn) {
|
|
2269
|
+
btn.innerHTML = '<span style="margin-right: 6px;">🔗</span> Connect Gmail';
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
async sendEmail(clientEmail) {
|
|
2275
|
+
this.buildPreview();
|
|
2276
|
+
|
|
2277
|
+
const clientName = Utils.$(CONFIG.SELECTORS.client).value;
|
|
2278
|
+
const invoiceNumber = Utils.$(CONFIG.SELECTORS.number).value;
|
|
2279
|
+
const freelancerName = Utils.$(CONFIG.SELECTORS.freelancer).value;
|
|
2280
|
+
const dueDate = Utils.$(CONFIG.SELECTORS.due).value;
|
|
2281
|
+
|
|
2282
|
+
// If Gmail not connected, use simple mailto method
|
|
2283
|
+
if (!gmailAuthorized || !gmailAccessToken) {
|
|
2284
|
+
await this.sendEmailSimple(clientEmail);
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
// Show loading state
|
|
2289
|
+
const sendBtn = Utils.$(CONFIG.SELECTORS.sendEmailBtn);
|
|
2290
|
+
const originalText = sendBtn.innerHTML;
|
|
2291
|
+
sendBtn.innerHTML = '<span style="font-size: 16px; margin-right: 6px;">⏳</span> Generating PDF...';
|
|
2292
|
+
sendBtn.disabled = true;
|
|
2293
|
+
|
|
2294
|
+
try {
|
|
2295
|
+
// Get period from first line item
|
|
2296
|
+
const firstItem = this.state.lineItems[0];
|
|
2297
|
+
let period = '';
|
|
2298
|
+
if (firstItem && firstItem.periodFrom && firstItem.periodTo) {
|
|
2299
|
+
period = `${firstItem.periodFrom} to ${firstItem.periodTo}`;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// Calculate total
|
|
2303
|
+
const subtotal = this.state.getSubtotal();
|
|
2304
|
+
const currency = Utils.$(CONFIG.SELECTORS.currency).value;
|
|
2305
|
+
const taxPct = parseFloat(Utils.$(CONFIG.SELECTORS.tax).value || 0);
|
|
2306
|
+
const tax = subtotal * (taxPct / 100);
|
|
2307
|
+
const discount = parseFloat(Utils.$(CONFIG.SELECTORS.discount).value || 0);
|
|
2308
|
+
const total = Math.max(0, subtotal + tax - discount);
|
|
2309
|
+
|
|
2310
|
+
// Generate filename
|
|
2311
|
+
let filename = 'Invoice';
|
|
2312
|
+
if (period) {
|
|
2313
|
+
filename = `Invoice_${invoiceNumber}_${period.replace(/ to /g, '_to_')}`;
|
|
2314
|
+
} else {
|
|
2315
|
+
filename = `Invoice_${invoiceNumber}`;
|
|
2316
|
+
}
|
|
2317
|
+
filename = filename.replace(/\s+/g, '_');
|
|
2318
|
+
|
|
2319
|
+
// Generate PDF as blob
|
|
2320
|
+
const element = document.querySelector('.sheet');
|
|
2321
|
+
const opt = {
|
|
2322
|
+
margin: [15, 15, 15, 15],
|
|
2323
|
+
filename: `${filename}.pdf`,
|
|
2324
|
+
image: { type: 'png', quality: 1.0 },
|
|
2325
|
+
html2canvas: {
|
|
2326
|
+
scale: 3,
|
|
2327
|
+
useCORS: true,
|
|
2328
|
+
logging: false,
|
|
2329
|
+
letterRendering: true,
|
|
2330
|
+
backgroundColor: '#ffffff'
|
|
2331
|
+
},
|
|
2332
|
+
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait', compress: true }
|
|
2333
|
+
};
|
|
2334
|
+
|
|
2335
|
+
sendBtn.innerHTML = '<span style="font-size: 16px; margin-right: 6px;">📧</span> Sending Email...';
|
|
2336
|
+
|
|
2337
|
+
// Generate PDF as blob
|
|
2338
|
+
const pdfBlob = await html2pdf().set(opt).from(element).outputPdf('blob');
|
|
2339
|
+
|
|
2340
|
+
// Convert blob to base64
|
|
2341
|
+
const reader = new FileReader();
|
|
2342
|
+
const pdfBase64 = await new Promise((resolve, reject) => {
|
|
2343
|
+
reader.onload = () => resolve(reader.result.split(',')[1]);
|
|
2344
|
+
reader.onerror = reject;
|
|
2345
|
+
reader.readAsDataURL(pdfBlob);
|
|
2346
|
+
});
|
|
2347
|
+
|
|
2348
|
+
// Email subject
|
|
2349
|
+
const subject = period
|
|
2350
|
+
? `Invoice ${invoiceNumber} | ${period}`
|
|
2351
|
+
: `Invoice ${invoiceNumber}`;
|
|
2352
|
+
|
|
2353
|
+
// Email body
|
|
2354
|
+
const body = `Hi ${clientName},
|
|
2355
|
+
|
|
2356
|
+
Please find the attached invoice.
|
|
2357
|
+
|
|
2358
|
+
Invoice #${invoiceNumber}${period ? ` | Period: ${period}` : ''}
|
|
2359
|
+
Amount Due: ${Utils.formatMoney(total, currency)}
|
|
2360
|
+
Due Date: ${dueDate}
|
|
2361
|
+
|
|
2362
|
+
Thank you!
|
|
2363
|
+
|
|
2364
|
+
${freelancerName}`;
|
|
2365
|
+
|
|
2366
|
+
// Create email with attachment (RFC 2822 format)
|
|
2367
|
+
const boundary = '----=_Part_' + Date.now();
|
|
2368
|
+
const email = [
|
|
2369
|
+
'Content-Type: multipart/mixed; boundary="' + boundary + '"',
|
|
2370
|
+
'MIME-Version: 1.0',
|
|
2371
|
+
'To: ' + clientEmail,
|
|
2372
|
+
'Subject: ' + subject,
|
|
2373
|
+
'',
|
|
2374
|
+
'--' + boundary,
|
|
2375
|
+
'Content-Type: text/plain; charset="UTF-8"',
|
|
2376
|
+
'MIME-Version: 1.0',
|
|
2377
|
+
'Content-Transfer-Encoding: 7bit',
|
|
2378
|
+
'',
|
|
2379
|
+
body,
|
|
2380
|
+
'',
|
|
2381
|
+
'--' + boundary,
|
|
2382
|
+
'Content-Type: application/pdf; name="' + filename + '.pdf"',
|
|
2383
|
+
'MIME-Version: 1.0',
|
|
2384
|
+
'Content-Transfer-Encoding: base64',
|
|
2385
|
+
'Content-Disposition: attachment; filename="' + filename + '.pdf"',
|
|
2386
|
+
'',
|
|
2387
|
+
pdfBase64,
|
|
2388
|
+
'--' + boundary + '--'
|
|
2389
|
+
].join('\r\n');
|
|
2390
|
+
|
|
2391
|
+
// Encode email
|
|
2392
|
+
const encodedEmail = btoa(unescape(encodeURIComponent(email)))
|
|
2393
|
+
.replace(/\+/g, '-')
|
|
2394
|
+
.replace(/\//g, '_')
|
|
2395
|
+
.replace(/=+$/, '');
|
|
2396
|
+
|
|
2397
|
+
// Send via Gmail API
|
|
2398
|
+
const response = await fetch('https://www.googleapis.com/gmail/v1/users/me/messages/send', {
|
|
2399
|
+
method: 'POST',
|
|
2400
|
+
headers: {
|
|
2401
|
+
'Authorization': `Bearer ${gmailAccessToken}`,
|
|
2402
|
+
'Content-Type': 'application/json'
|
|
2403
|
+
},
|
|
2404
|
+
body: JSON.stringify({
|
|
2405
|
+
raw: encodedEmail
|
|
2406
|
+
})
|
|
2407
|
+
});
|
|
2408
|
+
|
|
2409
|
+
if (!response.ok) {
|
|
2410
|
+
throw new Error(`Gmail API error: ${response.status}`);
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// Reset button and show success
|
|
2414
|
+
sendBtn.innerHTML = originalText;
|
|
2415
|
+
sendBtn.disabled = false;
|
|
2416
|
+
|
|
2417
|
+
alert(`✅ Email Sent Successfully!\n\n✓ Invoice sent to: ${clientEmail}\n✓ PDF attached: ${filename}.pdf\n✓ Subject: ${subject}\n\nThe email has been sent from your Gmail account!`);
|
|
2418
|
+
|
|
2419
|
+
} catch (error) {
|
|
2420
|
+
console.error('Error sending email:', error);
|
|
2421
|
+
const sendBtn = Utils.$(CONFIG.SELECTORS.sendEmailBtn);
|
|
2422
|
+
sendBtn.innerHTML = originalText;
|
|
2423
|
+
sendBtn.disabled = false;
|
|
2424
|
+
|
|
2425
|
+
// Fallback to simple method
|
|
2426
|
+
if (confirm('Gmail API send failed. Use simple method (download PDF + open Gmail)?')) {
|
|
2427
|
+
await this.sendEmailSimple();
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
async sendEmailSimple(clientEmail) {
|
|
2433
|
+
this.buildPreview();
|
|
2434
|
+
|
|
2435
|
+
const clientName = Utils.$(CONFIG.SELECTORS.client).value;
|
|
2436
|
+
const invoiceNumber = Utils.$(CONFIG.SELECTORS.number).value;
|
|
2437
|
+
const freelancerName = Utils.$(CONFIG.SELECTORS.freelancer).value;
|
|
2438
|
+
const dueDate = Utils.$(CONFIG.SELECTORS.due).value;
|
|
2439
|
+
|
|
2440
|
+
// Show loading state
|
|
2441
|
+
const sendBtn = Utils.$(CONFIG.SELECTORS.sendEmailBtn);
|
|
2442
|
+
const originalText = sendBtn.innerHTML;
|
|
2443
|
+
sendBtn.innerHTML = '<span style="font-size: 16px; margin-right: 6px;">⏳</span> Generating PDF...';
|
|
2444
|
+
sendBtn.disabled = true;
|
|
2445
|
+
|
|
2446
|
+
try {
|
|
2447
|
+
// Get period from first line item
|
|
2448
|
+
const firstItem = this.state.lineItems[0];
|
|
2449
|
+
let period = '';
|
|
2450
|
+
if (firstItem && firstItem.periodFrom && firstItem.periodTo) {
|
|
2451
|
+
period = `${firstItem.periodFrom} to ${firstItem.periodTo}`;
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// Calculate total
|
|
2455
|
+
const subtotal = this.state.getSubtotal();
|
|
2456
|
+
const currency = Utils.$(CONFIG.SELECTORS.currency).value;
|
|
2457
|
+
const taxPct = parseFloat(Utils.$(CONFIG.SELECTORS.tax).value || 0);
|
|
2458
|
+
const tax = subtotal * (taxPct / 100);
|
|
2459
|
+
const discount = parseFloat(Utils.$(CONFIG.SELECTORS.discount).value || 0);
|
|
2460
|
+
const total = Math.max(0, subtotal + tax - discount);
|
|
2461
|
+
|
|
2462
|
+
// Generate filename
|
|
2463
|
+
let filename = 'Invoice';
|
|
2464
|
+
if (period) {
|
|
2465
|
+
filename = `Invoice_${invoiceNumber}_${period.replace(/ to /g, '_to_')}`;
|
|
2466
|
+
} else {
|
|
2467
|
+
filename = `Invoice_${invoiceNumber}`;
|
|
2468
|
+
}
|
|
2469
|
+
filename = filename.replace(/\s+/g, '_');
|
|
2470
|
+
|
|
2471
|
+
// Generate PDF
|
|
2472
|
+
const element = document.querySelector('.sheet');
|
|
2473
|
+
const opt = {
|
|
2474
|
+
margin: [15, 15, 15, 15],
|
|
2475
|
+
filename: `${filename}.pdf`,
|
|
2476
|
+
image: { type: 'png', quality: 1.0 },
|
|
2477
|
+
html2canvas: {
|
|
2478
|
+
scale: 3,
|
|
2479
|
+
useCORS: true,
|
|
2480
|
+
logging: false,
|
|
2481
|
+
letterRendering: true,
|
|
2482
|
+
backgroundColor: '#ffffff'
|
|
2483
|
+
},
|
|
2484
|
+
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait', compress: true }
|
|
2485
|
+
};
|
|
2486
|
+
|
|
2487
|
+
// Generate and download PDF
|
|
2488
|
+
await html2pdf().set(opt).from(element).save();
|
|
2489
|
+
|
|
2490
|
+
// Simple subject
|
|
2491
|
+
const subject = period
|
|
2492
|
+
? `Invoice ${invoiceNumber} | ${period}`
|
|
2493
|
+
: `Invoice ${invoiceNumber}`;
|
|
2494
|
+
|
|
2495
|
+
// Concise email body
|
|
2496
|
+
const body = `Hi ${clientName},
|
|
2497
|
+
|
|
2498
|
+
Please find the attached invoice.
|
|
2499
|
+
|
|
2500
|
+
Invoice #${invoiceNumber}${period ? ` | Period: ${period}` : ''}
|
|
2501
|
+
Amount Due: ${Utils.formatMoney(total, currency)}
|
|
2502
|
+
Due Date: ${dueDate}
|
|
2503
|
+
|
|
2504
|
+
Thank you!
|
|
2505
|
+
|
|
2506
|
+
${freelancerName}`;
|
|
2507
|
+
|
|
2508
|
+
// Open Gmail with pre-filled content
|
|
2509
|
+
const gmailUrl = `https://mail.google.com/mail/?view=cm&fs=1&to=${encodeURIComponent(clientEmail)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
2510
|
+
|
|
2511
|
+
// Small delay to let PDF download start
|
|
2512
|
+
setTimeout(() => {
|
|
2513
|
+
window.open(gmailUrl, '_blank');
|
|
2514
|
+
|
|
2515
|
+
// Reset button and show success
|
|
2516
|
+
sendBtn.innerHTML = originalText;
|
|
2517
|
+
sendBtn.disabled = false;
|
|
2518
|
+
|
|
2519
|
+
setTimeout(() => {
|
|
2520
|
+
const message = `✅ PDF Downloaded!\n\n` +
|
|
2521
|
+
`✓ File: "${filename}.pdf"\n` +
|
|
2522
|
+
`✓ Gmail opened with pre-filled message\n\n` +
|
|
2523
|
+
`📎 NEXT STEP:\n` +
|
|
2524
|
+
`In Gmail, click the paperclip icon and attach the PDF\n\n` +
|
|
2525
|
+
`To: ${clientEmail}\n\n` +
|
|
2526
|
+
`💡 TIP: Connect your Gmail account to send with automatic PDF attachment!`;
|
|
2527
|
+
alert(message);
|
|
2528
|
+
}, 500);
|
|
2529
|
+
}, 1000);
|
|
2530
|
+
|
|
2531
|
+
} catch (error) {
|
|
2532
|
+
console.error('Error generating PDF:', error);
|
|
2533
|
+
const sendBtn = Utils.$(CONFIG.SELECTORS.sendEmailBtn);
|
|
2534
|
+
sendBtn.innerHTML = originalText;
|
|
2535
|
+
sendBtn.disabled = false;
|
|
2536
|
+
alert('Error generating PDF. Please try using the "Print / PDF" button instead.');
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
updateDocumentTitle() {
|
|
2541
|
+
// Get the first line item's period dates
|
|
2542
|
+
const firstItem = this.state.lineItems[0];
|
|
2543
|
+
let filename = 'Invoice';
|
|
2544
|
+
|
|
2545
|
+
if (firstItem && firstItem.periodFrom && firstItem.periodTo) {
|
|
2546
|
+
filename = `Invoice_${firstItem.periodFrom}_to_${firstItem.periodTo}`;
|
|
2547
|
+
} else if (firstItem && firstItem.periodFrom) {
|
|
2548
|
+
filename = `Invoice_from_${firstItem.periodFrom}`;
|
|
2549
|
+
} else if (firstItem && firstItem.periodTo) {
|
|
2550
|
+
filename = `Invoice_to_${firstItem.periodTo}`;
|
|
2551
|
+
} else {
|
|
2552
|
+
// Fallback to invoice dates if no period is set
|
|
2553
|
+
const invoiceDate = Utils.$(CONFIG.SELECTORS.date).value;
|
|
2554
|
+
const dueDate = Utils.$(CONFIG.SELECTORS.due).value;
|
|
2555
|
+
if (invoiceDate && dueDate) {
|
|
2556
|
+
filename = `Invoice_${invoiceDate}_to_${dueDate}`;
|
|
2557
|
+
} else if (invoiceDate) {
|
|
2558
|
+
filename = `Invoice_${invoiceDate}`;
|
|
2559
|
+
} else {
|
|
2560
|
+
// Use invoice number as final fallback
|
|
2561
|
+
const invoiceNum = Utils.$(CONFIG.SELECTORS.number).value || '0001';
|
|
2562
|
+
filename = `Invoice_${invoiceNum}`;
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
document.title = filename;
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
// ============================================================================
|
|
2571
|
+
// INITIALIZATION
|
|
2572
|
+
// ============================================================================
|
|
2573
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
2574
|
+
const state = new InvoiceState();
|
|
2575
|
+
const builder = new InvoiceBuilder(state);
|
|
2576
|
+
// Expose for automation (Puppeteer)
|
|
2577
|
+
window.__invoicer = { state, builder };
|
|
2578
|
+
});
|
|
2579
|
+
</script>
|
|
2580
|
+
</body>
|
|
2581
|
+
</html>
|
|
2582
|
+
|
|
2583
|
+
|
|
2584
|
+
|