@invoicer/cli 1.1.2 → 1.2.1

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/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">&times;</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
+