Close Menu
  • Articles
    • Learn Electronics
    • Product Review
    • Tech Articles
  • Electronics Circuits
    • 555 Timer Projects
    • Op-Amp Circuits
    • Power Electronics
  • Microcontrollers
    • Arduino Projects
    • STM32 Projects
    • AMB82-Mini IoT AI Camera
    • BLE Projects
  • IoT Projects
    • ESP8266 Projects
    • ESP32 Projects
    • ESP32 MicroPython
    • ESP32-CAM Projects
    • LoRa/LoRaWAN Projects
  • Raspberry Pi
    • Raspberry Pi Projects
    • Raspberry Pi Pico Projects
    • Raspberry Pi Pico W Projects
  • Electronics Calculator
Facebook X (Twitter) Instagram
  • About Us
  • Disclaimer
  • Privacy Policy
  • Contact Us
  • Advertise With Us
Facebook X (Twitter) Instagram Pinterest YouTube LinkedIn
How To Electronics
  • Articles
    • Learn Electronics
    • Product Review
    • Tech Articles
  • Electronics Circuits
    • 555 Timer Projects
    • Op-Amp Circuits
    • Power Electronics
  • Microcontrollers
    • Arduino Projects
    • STM32 Projects
    • AMB82-Mini IoT AI Camera
    • BLE Projects
  • IoT Projects
    • ESP8266 Projects
    • ESP32 Projects
    • ESP32 MicroPython
    • ESP32-CAM Projects
    • LoRa/LoRaWAN Projects
  • Raspberry Pi
    • Raspberry Pi Projects
    • Raspberry Pi Pico Projects
    • Raspberry Pi Pico W Projects
  • Electronics Calculator
How To Electronics
Home » ESP32 Fingerprint Voting Machine with Live Web Dashboard
ESP32 Projects IoT Projects

ESP32 Fingerprint Voting Machine with Live Web Dashboard

Mamtaz AlamBy Mamtaz AlamUpdated:July 12, 202613 Mins Read
Share Facebook Twitter LinkedIn Telegram Reddit WhatsApp
ESP32 Fingerprint Voting Machine with Live Web Dashboard
Share
Facebook Twitter LinkedIn Pinterest Email Reddit Telegram WhatsApp

Overview

In this IoT project, we will learn how to build an ESP32 Fingerprint Voting Machine with Live Web Dashboard using ESP32, R503 Fingerprint Sensor, 0.96” OLED Display, four candidate push buttons, and a professional web dashboard. The ESP32 Wi-Fi module connects the system to a local network and hosts a real-time voting dashboard directly from the device. The R503 fingerprint sensor is used to enroll voters and verify identity before voting, while the OLED display shows system status, scan instructions, voter name, candidate selection prompts, vote confirmation, and registration messages.

This ESP32-based biometric voting machine allows voters to register their fingerprint along with personal details such as name, ID number, date of birth, sex, and city. Once a registered voter scans their finger, the system opens a short voting window and the voter presses one of four candidate buttons to cast a vote. Each fingerprint can vote only once. The dashboard displays live vote distribution, turnout, leading candidate, live activity feed, vote history, registered voter records, and election results. Vote data can also be exported as a CSV file for record-keeping or analysis.

The live web dashboard makes this project more advanced by providing a user-friendly interface for voter enrollment, live election monitoring, vote history, voter management, election reset, and fingerprint deletion. This project is a practical and low-cost solution for building a secure electronic voting machine with real-time results and biometric voter verification.


Components Required

Following are the components required to make the ESP32 Fingerprint Voting Machine with Live Web Dashboard. All these components can be purchased from the given links.

S.N.Components NameQuantityPurchase Link
1ESP32 Board1Amazon | AliExpress
2R503 Fingerprint Sensor1Amazon | AliExpress
3OLED Display 0.96"1Amazon | AliExpress
410uF Electrolytic Capacitor2Amazon | AliExpress
50.1uF Ceramic Capacitor4Amazon | AliExpress
6Jumper Wires4Amazon | AliExpress

Block Diagram of ESP32 Fingerprint Voting Machine

Let’s take a look at the block diagram of the ESP32-based Fingerprint Voting Machine with Live Web Dashboard.

Block Diagram ESP32 Fingerprint Attendance System
Fig: Block Diagram ESP32 Fingerprint Voting Machine

In this system, the R503 fingerprint sensor works as the input device and sends fingerprint data to the ESP32 for voter verification. After a registered voter is verified, the ESP32 displays instructions on the OLED screen, waits for a candidate button press, records the vote, and updates the web dashboard for real-time election monitoring.

The dashboard can show registered voters, live vote counts, turnout, leading candidate, vote history, and other election details in a simple and organized way.


R503 Fingerprint Module

The R503 fingerprint sensor is a compact biometric fingerprint identification module used for fingerprint enrollment, storage, and matching. It uses an optical fingerprint sensing system to capture a finger image, extract fingerprint features, and compare them with stored templates inside the module.

R503 Fingerprint Module

The sensor supports both 1:1 matching and 1:N searching. During enrollment, the user places the same finger two times. The module processes both fingerprint images, generates a fingerprint template, and stores it in its internal memory. During verification, the module captures a new fingerprint image and compares it with stored templates to identify whether the fingerprint matches or not.

Features & Specifications

The R503 fingerprint sensor is suitable for security systems, attendance systems, access control, smart locks, and embedded biometric projects.

Feature Specification
Operating Voltage DC 3.3V
Interface UART, 3.3V TTL logic level
Default Baud Rate 57600 bps
Baud Rate Range 9600 to 115200 bps
Matching Mode 1:1 and 1:N
Fingerprint Storage Capacity 200 templates
Image Resolution 508 dpi
Sensing Array 192 × 192 pixels
Detection Area 15 mm diameter
Fingerprint Image Capture Time Less than 0.2 seconds
Feature Generation Time Less than 500 ms
Template Size 768 bytes
Characteristic Value Size 384 bytes
Security Levels 1 to 5, with 5 being the highest
False Acceptance Rate, FAR Less than 0.001%
False Rejection Rate, FRR Less than 1%
Working Current Around 20 mA during fingerprint acquisition
Standby Current Around 2 µA in touch standby mode
Working Temperature -20°C to +60°C
Storage Temperature -40°C to +75°C

The module also includes functions for enrolling fingerprints, deleting templates, clearing the fingerprint library, searching stored fingerprints, reading template count, controlling the LED ring, and checking sensor status. It communicates using a serial UART protocol, making it easy to use with microcontrollers such as ESP32, Arduino, STM32, and Raspberry Pi Pico.

Wiring

The R503 fingerprint sensor uses a 6-pin MX1.0 connector. It communicates with the microcontroller through UART serial communication.

R503 Pin Pin Name Description
1 Power Supply DC 3.3V
2 GND Ground
3 TXD Data output from sensor
4 RXD Data input to sensor
5 WAKEUP Finger detection signal
6 3.3VT Touch induction power supply, DC 3–6V

Circuit Diagram

Let’s take a look at the schematic of the ESP32-based Fingerprint Voting Machine.

Fig: Schematic ESP32-based Fingerprint Voting Machine

In this circuit, the ESP32 works as the main controller and is connected with the R503 fingerprint sensor, OLED display, and four candidate push buttons on GPIO pins D5, D18, D19, and D23. The R503 fingerprint sensor communicates with the ESP32 through UART pins, while the OLED display uses the I2C pins for showing scan status, voter name, candidate selection prompts, vote confirmation, and system messages.

The four candidate push buttons are connected as active-low inputs with internal pull-up resistors. After a registered voter scans their fingerprint, they press one button to vote for Candidate 1, 2, 3, or 4. Voter enrollment is done only from the web dashboard, not from physical buttons. Pull-up resistors are used with the buttons, so the ESP32 can read a stable HIGH or LOW signal when a button is pressed. Capacitors are also added near the modules to make the power supply more stable and reduce noise in the circuit.


PCB Designing & Gerber Files

The schematic of the ESP32 Fingerprint Voting Machine was created in EasyEDA, and from there it was converted into a compact PCB layout.

The board is designed with mostly through-hole components to make easier assembly. You can view the 2D view of the PCB.

All of the critical components, including the ESP32 module, are placed on the front side of the PCB, which makes assembly easier and more reliable.

Here are the link of files that you can download for PCB manufacturing and PCB assembly services.

  1. Download: Schematic PDF
  2. Download: Gerber File

PCB Ordering Online & Assembly

The Gerber file for this ESP32 Fingerprint Voting Machine is provided above. You can download the Gerber file and place an order with a PCB manufacturer like AIVON for as low as $1 for a PCB Prototype.

To order the PCB, visit the AIVON Official Website and upload the Gerber file using the Quote Now option. You can then choose your required parameters, such as Material Type, Dimensions, Quantity, Thickness, and Solder Mask Color.

AIVON is making PCB prototyping and assembly more affordable for new users by offering $1 PCB Prototype and $35 PCB Assembly with Shipping fee $30 OFF on your first PCB order. With this promotion, you can enjoy free shipping on your first order and affordable assembly service for your project.

Here is the promotion link: AIVON PCB/PCBA Promotion Offer

Once all the details are filled in, select your country and shipping method. After confirming everything, you can place the order and wait for your boards to arrive.


Assembly of the ESP32 Fingerprint Voting Machine

After I ordered the custom PCB, I received it within just 3 days. The PCB quality was very good, and it looked clean and professional. It is a double-sided PCB, which makes the circuit compact and neat.

Then I started soldering and assembling all the components on the board. For the ESP32 board, OLED display, fingerprint sensor, and candidate buttons, I used female header pins where needed. This makes it easy to remove or replace the modules later if required.

I also soldered the required capacitors on different parts of the PCB. These capacitors help to keep the power supply stable and reduce noise in the circuit. For the R503 Fingerprint sensor, I used 6 jumper wires to connect it to the PCB board terminal.

After soldering all the parts, I connected all the modules and completed the final assembly of the IoT ESP32 Fingerprint Voting Machine device.


Source Code/program

Let us take a look at the programming part of this project.

The code starts by including the required libraries for Wi-Fi, web server, fingerprint sensor, OLED display, and ESP32 memory storage. The WiFi and WebServer libraries are used to connect the ESP32 to the local network and host the live voting dashboard. The Adafruit Fingerprint library is used to communicate with the R503 fingerprint sensor, while the Adafruit GFX library and Adafruit SSD1306 library are used to display messages on the OLED screen.

In the Wi-Fi section, the SSID and password are added so the ESP32 can connect to the network. After connecting, the ESP32 shows its IP address on the OLED display and Serial Monitor. This IP address can be opened in a browser to access the professional web dashboard.

The main loop checks for fingerprint scans, dashboard enrollment requests, and candidate button presses. When a registered voter scans their finger, the ESP32 verifies the fingerprint, checks whether that voter has already voted, opens a voting window, and records the selected candidate when a button is pressed. Vote counts and voter status are saved in ESP32 NVS memory and shown on the dashboard in real time. New voters can be registered from the dashboard by entering name, ID number, date of birth, sex, and city.

The code is divided into two main parts: the main FPS_Voting_Machine.ino file for ESP32 voting control and the dashboard.h file for the live web dashboard interface.

Main .ino code

Copy the following code and paste it to your Arduino IDE editor window.

C++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
#include <Wire.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <time.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_Fingerprint.h>
#include "dashboard.h"
 
// =====================================================
// WIFI SETTINGS
// =====================================================
const char* WIFI_SSID = "****************";
const char* WIFI_PASSWORD = "***************";
 
// Ontario / Toronto timezone
const char* TIME_ZONE = "EST5EDT,M3.2.0/2,M11.1.0/2";
 
WebServer server(80);
Preferences prefs;
 
// =====================================================
// OLED SETTINGS
// =====================================================
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define OLED_ADDR 0x3C
 
#define OLED_SDA 21
#define OLED_SCL 22
 
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
 
// =====================================================
// R503 FINGERPRINT SENSOR UART
// =====================================================
#define FINGER_RX 16
#define FINGER_TX 17
 
HardwareSerial fingerSerial(2);
Adafruit_Fingerprint finger = Adafruit_Fingerprint(&fingerSerial);
 
// =====================================================
// CANDIDATE BUTTONS ONLY (active LOW, internal pull-up)
// D5/D18/D19/D23 = Candidate 1-4 for voting after fingerprint scan.
// Voter enrollment is dashboard-only (POST /api/enroll) — no enroll button.
// =====================================================
#define BTN_CAND_1  5
#define BTN_CAND_2  18
#define BTN_CAND_3  19
#define BTN_CAND_4  23
#define NUM_CANDIDATES 4
#define VOTE_TIMEOUT_MS 15000
#define DEBOUNCE_MS 250
 
const uint8_t CAND_PINS[NUM_CANDIDATES] = {BTN_CAND_1, BTN_CAND_2, BTN_CAND_3, BTN_CAND_4};
const char* CANDIDATE_NAMES[NUM_CANDIDATES] = {
  "Candidate 1", "Candidate 2", "Candidate 3", "Candidate 4"
};
 
unsigned long lastCandBtnTime[NUM_CANDIDATES] = {0, 0, 0, 0};
 
// =====================================================
// MATCH SETTINGS
// =====================================================
#define MATCH_SECURITY_LEVEL 2
#define MAX_FINGERPRINTS 200
 
// =====================================================
// PROFILE STORAGE
// =====================================================
struct PersonProfile {
  String name;
  String collegeId;
  String role;
  String department;
  String note;
};
 
// =====================================================
// LOGGING
// =====================================================
#define MAX_LOGS 80
 
struct AttendanceLog {
  String timeText;
  String event;
  int id;
  String name;
  String collegeId;
  String role;
  String department;
  String note;
  String candidate;
  String message;
};
 
AttendanceLog logs[MAX_LOGS];
int logCount = 0;
int logWriteIndex = 0;
 
// =====================================================
// APP STATE
// =====================================================
uint16_t nextEnrollID = 1;
unsigned long lastIdleRefresh = 0;
 
bool isEnrolling = false;
bool cancelEnroll = false;
bool duplicateFound = false;
bool dashboardEnrollRequest = false;
 
uint16_t cachedTemplateCount = 0;
String cachedRegisteredJson = "{\"fingerprints\":[]}";
 
int candidateVotes[NUM_CANDIDATES] = {0, 0, 0, 0};
bool hasVoted[MAX_FINGERPRINTS + 1];
 
bool isSelectingCandidate = false;
int pendingVoterId = -1;
unsigned long voteWindowStart = 0;
 
// Dashboard form values
String pendingName = "";
String pendingCollegeId = "";
String pendingRole = "";
String pendingDepartment = "";
String pendingNote = "";
 
// =====================================================
// FUNCTION DECLARATIONS
// =====================================================
void connectWiFi();
void setupTime();
void setupDashboardRoutes();
 
void smartDelay(unsigned long ms);
 
String uptimeText();
String currentDateTimeText();
String jsonEscape(String s);
String firstNameOnly(String fullName);
 
void addLog(String event, int id, String message, String candidate = "");
AttendanceLog getLogByDisplayIndex(int i);
 
void loadVoteState();
void saveVoteForVoter(uint16_t id);
void saveCandidateVotes();
void clearVoterVoteState(uint16_t id);
void resetAllVotes();
int getTotalVotes();
void castVote(int voterId, int candidateIndex);
void handleCandidateSelection();
 
String profileKey(uint16_t id, const char* field);
void saveProfile(uint16_t id, PersonProfile p);
PersonProfile loadProfile(uint16_t id);
void deleteProfile(uint16_t id);
 
void oledScreen(String title, String line1 = "", String line2 = "", String line3 = "");
void oledBig(String title, String line1 = "", String line2 = "");
void oledScanScreen();
 
void oledScanAnimation(String title, String line1, uint16_t durationMs);
void oledProgressBar(String title, String line1, uint8_t percent);
void oledSuccessAnimation(String line1, String line2 = "");
void oledErrorAnimation(String line1, String line2 = "");
 
String clipText(String text, uint8_t maxChars);
void drawCenteredText(String text, int y, uint8_t textSize);
void drawTitleBar(String title);
void drawMiniFingerprint(int x, int y);
void drawLoadingDots(int x, int y, uint8_t frame);
void drawMovingBar(int y, uint8_t frame);
void drawProgress(uint8_t percent);
void drawWiFiIcon(bool connected);
 
bool buttonPressed(uint8_t pin, unsigned long &lastTime);
int readCandidateButton();
bool checkCancelDuringEnroll();
 
void scanFingerprint();
uint8_t enrollFingerprint(uint16_t id);
 
void waitForFingerRemove();
uint16_t findNextFreeID(uint16_t startID);
void printSensorParameters();
void refreshTemplateCountSerialOnly();
void refreshTemplateCountCache();
void rebuildRegisteredCache();
void setMatchingSecuritySerialOnly();
 
// =====================================================
// SETUP
// =====================================================
void setup() {
  Serial.begin(115200);
  delay(1000);
 
  for (int i = 0; i < NUM_CANDIDATES; i++) {
    pinMode(CAND_PINS[i], INPUT_PULLUP);
  }
 
  for (int i = 0; i <= MAX_FINGERPRINTS; i++) {
    hasVoted[i] = false;
  }
 
  prefs.begin("fps_db", false);
  loadVoteState();
 
  Wire.begin(OLED_SDA, OLED_SCL);
 
  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
    Serial.println("OLED not found");
    while (1) {
      delay(1000);
    }
  }
 
  display.clearDisplay();
  display.setTextWrap(false);
  display.display();
 
  oledBig("STARTING", "Fingerprint", "Voting System");
  smartDelay(900);
 
  connectWiFi();
  setupTime();
  setupDashboardRoutes();
 
  fingerSerial.begin(57600, SERIAL_8N1, FINGER_RX, FINGER_TX);
  finger.begin(57600);
 
  smartDelay(500);
 
  oledScanAnimation("CHECKING", "Sensor", 900);
 
  if (finger.verifyPassword()) {
    Serial.println("Fingerprint sensor found!");
    addLog("INFO", -1, "Fingerprint sensor ready");
    oledSuccessAnimation("Sensor ready");
    smartDelay(900);
  } else {
    Serial.println("Fingerprint sensor not found!");
    addLog("ERROR", -1, "Fingerprint sensor not found");
    oledErrorAnimation("Sensor not found", "Check wiring");
 
    while (1) {
      server.handleClient();
      delay(10);
    }
  }
 
  printSensorParameters();
  setMatchingSecuritySerialOnly();
 
  refreshTemplateCountSerialOnly();
  rebuildRegisteredCache();
 
  nextEnrollID = findNextFreeID(1);
 
  Serial.print("Next enroll ID: ");
  Serial.println(nextEnrollID);
 
  addLog("INFO", -1, "System ready");
 
  smartDelay(500);
  oledScanScreen();
}
 
// =====================================================
// LOOP
// =====================================================
void loop() {
  server.handleClient();
 
  // Enrollment starts ONLY from the web dashboard (never from physical buttons).
  if (dashboardEnrollRequest && !isEnrolling && !isSelectingCandidate) {
    dashboardEnrollRequest = false;
 
    if (pendingName.length() > 0) {
      addLog("ENROLL", -1, "Enrollment started for " + pendingName);
    } else {
      addLog("ENROLL", -1, "Enrollment started");
    }
 
    isEnrolling = true;
    cancelEnroll = false;
    duplicateFound = false;
 
    uint8_t result = enrollFingerprint(nextEnrollID);
 
    if (cancelEnroll) {
      addLog("CANCEL", -1, "Enrollment cancelled");
      oledBig("CANCELLED", "Back to scan");
      smartDelay(1200);
    }
    else if (duplicateFound) {
      addLog("DUPLICATE", finger.fingerID, "Fingerprint already exists");
      smartDelay(1200);
    }
    else if (result == FINGERPRINT_OK) {
      PersonProfile p;
      p.name = pendingName;
      p.collegeId = pendingCollegeId;
      p.role = pendingRole;
      p.department = pendingDepartment;
      p.note = pendingNote;
 
      saveProfile(nextEnrollID, p);
 
      String savedName = firstNameOnly(p.name);
 
      if (savedName.length() == 0) {
        savedName = "ID " + String(nextEnrollID);
      }
 
      String enrollMsg = "Fingerprint enrolled successfully";
 
      if (p.name.length() > 0) {
        enrollMsg = p.name + " enrolled successfully";
      }
 
      addLog("ENROLLED", nextEnrollID, enrollMsg);
 
      oledSuccessAnimation("Saved", savedName);
 
      Serial.print("Enrollment successful. ID: ");
      Serial.println(nextEnrollID);
 
      smartDelay(1200);
 
      refreshTemplateCountSerialOnly();
      rebuildRegisteredCache();
      nextEnrollID = findNextFreeID(nextEnrollID + 1);
    }
    else {
      addLog("ERROR", -1, "Enrollment failed");
      oledErrorAnimation("Not saved", "Try again");
      smartDelay(1600);
    }
 
    pendingName = "";
    pendingCollegeId = "";
    pendingRole = "";
    pendingDepartment = "";
    pendingNote = "";
 
    isEnrolling = false;
    cancelEnroll = false;
    duplicateFound = false;
 
    oledScanScreen();
  }
 
  if (isSelectingCandidate) {
    handleCandidateSelection();
  }
 
  if (!isEnrolling && !isSelectingCandidate) {
    scanFingerprint();
  }
 
  delay(30);
}
 
// =====================================================
// SMART DELAY
// =====================================================
void smartDelay(unsigned long ms) {
  unsigned long start = millis();
 
  while (millis() - start < ms) {
    server.handleClient();
    delay(5);
  }
}
 
// =====================================================
// WIFI + TIME + DASHBOARD ROUTES
// =====================================================
void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
 
  unsigned long start = millis();
  uint8_t frame = 0;
 
  while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) {
    display.clearDisplay();
    drawTitleBar("WIFI");
    drawCenteredText("Connecting", 24, 1);
    drawLoadingDots(28, 42, frame);
    display.display();
 
    frame++;
    delay(300);
  }
 
  if (WiFi.status() == WL_CONNECTED) {
    Serial.print("WiFi connected. IP: ");
    Serial.println(WiFi.localIP());
 
    oledBig("WIFI OK", "IP Address", WiFi.localIP().toString());
    smartDelay(5000);
  } else {
    Serial.println("WiFi not connected.");
    oledBig("WIFI OFF", "Dashboard offline");
    smartDelay(1800);
  }
}
 
void setupTime() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("WiFi not connected. NTP skipped.");
    return;
  }
 
  configTzTime(TIME_ZONE, "pool.ntp.org", "time.nist.gov", "time.google.com");
 
  Serial.print("Syncing time");
 
  struct tm timeinfo;
  int retry = 0;
 
  while (!getLocalTime(&timeinfo, 1000) && retry < 12) {
    Serial.print(".");
    retry++;
    server.handleClient();
  }
 
  Serial.println();
 
  if (retry < 12) {
    Serial.print("Time synced: ");
    Serial.println(currentDateTimeText());
  } else {
    Serial.println("Time sync failed. Using uptime fallback.");
  }
}
 
void setupDashboardRoutes() {
  server.on("/", HTTP_GET, []() {
    server.send_P(200, "text/html", DASHBOARD_HTML);
  });
 
  server.on("/api/status", HTTP_GET, []() {
    String lastJson = "null";
 
    if (logCount > 0) {
      AttendanceLog l = getLogByDisplayIndex(logCount - 1);
 
      lastJson = "{";
      lastJson += "\"time\":\"" + jsonEscape(l.timeText) + "\",";
      lastJson += "\"event\":\"" + jsonEscape(l.event) + "\",";
      lastJson += "\"id\":" + String(l.id) + ",";
      lastJson += "\"name\":\"" + jsonEscape(l.name) + "\",";
      lastJson += "\"collegeId\":\"" + jsonEscape(l.collegeId) + "\",";
      lastJson += "\"role\":\"" + jsonEscape(l.role) + "\",";
      lastJson += "\"department\":\"" + jsonEscape(l.department) + "\",";
      lastJson += "\"note\":\"" + jsonEscape(l.note) + "\",";
      lastJson += "\"candidate\":\"" + jsonEscape(l.candidate) + "\",";
      lastJson += "\"message\":\"" + jsonEscape(l.message) + "\"";
      lastJson += "}";
    }
 
    String voteCountsJson = "[";
 
    for (int i = 0; i < NUM_CANDIDATES; i++) {
      if (i > 0) {
        voteCountsJson += ",";
      }
 
      voteCountsJson += String(candidateVotes[i]);
    }
 
    voteCountsJson += "]";
 
    String candidatesJson = "[";
 
    for (int i = 0; i < NUM_CANDIDATES; i++) {
      if (i > 0) {
        candidatesJson += ",";
      }
 
      candidatesJson += "\"" + jsonEscape(String(CANDIDATE_NAMES[i])) + "\"";
    }
 
    candidatesJson += "]";
 
    String json = "{";
    json += "\"wifi\":" + String(WiFi.status() == WL_CONNECTED ? "true" : "false") + ",";
    json += "\"ip\":\"" + String(WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString() : "") + "\",";
    json += "\"templates\":" + String(cachedTemplateCount) + ",";
    json += "\"nextId\":" + String(nextEnrollID) + ",";
    json += "\"logCount\":" + String(logCount) + ",";
    json += "\"voteCounts\":" + voteCountsJson + ",";
    json += "\"totalVotes\":" + String(getTotalVotes()) + ",";
    json += "\"totalVoters\":" + String(cachedTemplateCount) + ",";
    json += "\"candidates\":" + candidatesJson + ",";
    json += "\"isEnrolling\":" + String(isEnrolling ? "true" : "false") + ",";
    json += "\"isSelectingVote\":" + String(isSelectingCandidate ? "true" : "false") + ",";
    json += "\"pendingVoterId\":" + String(pendingVoterId) + ",";
    json += "\"sensor\":\"Ready\",";
    json += "\"last\":" + lastJson;
    json += "}";
 
    server.send(200, "application/json", json);
  });
 
  server.on("/api/logs", HTTP_GET, []() {
    String json = "{\"logs\":[";
 
    for (int i = 0; i < logCount; i++) {
      AttendanceLog l = getLogByDisplayIndex(i);
 
      if (i > 0) {
        json += ",";
      }
 
      json += "{";
      json += "\"time\":\"" + jsonEscape(l.timeText) + "\",";
      json += "\"event\":\"" + jsonEscape(l.event) + "\",";
      json += "\"id\":" + String(l.id) + ",";
      json += "\"name\":\"" + jsonEscape(l.name) + "\",";
      json += "\"collegeId\":\"" + jsonEscape(l.collegeId) + "\",";
      json += "\"role\":\"" + jsonEscape(l.role) + "\",";
      json += "\"department\":\"" + jsonEscape(l.department) + "\",";
      json += "\"note\":\"" + jsonEscape(l.note) + "\",";
      json += "\"candidate\":\"" + jsonEscape(l.candidate) + "\",";
      json += "\"message\":\"" + jsonEscape(l.message) + "\"";
      json += "}";
    }
 
    json += "]}";
 
    server.send(200, "application/json", json);
  });
 
  server.on("/api/registered", HTTP_GET, []() {
    server.send(200, "application/json", cachedRegisteredJson);
  });
 
  server.on("/api/enroll", HTTP_POST, []() {
    if (isEnrolling || dashboardEnrollRequest) {
      server.send(409, "application/json", "{\"ok\":false,\"message\":\"Enrollment already running\"}");
      return;
    }
 
    pendingName = server.arg("name");
    pendingCollegeId = server.arg("collegeId");
    pendingRole = server.arg("role");
    pendingDepartment = server.arg("department");
    pendingNote = server.arg("note");
 
    dashboardEnrollRequest = true;
 
    String msg = "Enroll requested from dashboard";
 
    if (pendingName.length() > 0) {
      msg += " for " + pendingName;
    }
 
    addLog("ENROLL REQUEST", -1, msg);
 
    server.send(200, "application/json", "{\"ok\":true,\"message\":\"Enrollment requested\"}");
  });
 
  server.on("/api/cancel", HTTP_POST, []() {
    cancelEnroll = true;
    dashboardEnrollRequest = false;
 
    addLog("CANCEL", -1, "Cancel requested from dashboard");
 
    server.send(200, "application/json", "{\"ok\":true,\"message\":\"Cancel requested\"}");
  });
 
  server.on("/api/fingerprint", HTTP_DELETE, []() {
    if (!server.hasArg("id")) {
      server.send(400, "application/json", "{\"ok\":false,\"message\":\"Missing ID\"}");
      return;
    }
 
    int id = server.arg("id").toInt();
 
    if (id < 1 || id > MAX_FINGERPRINTS) {
      server.send(400, "application/json", "{\"ok\":false,\"message\":\"Invalid ID\"}");
      return;
    }
 
    if (isEnrolling) {
      server.send(409, "application/json", "{\"ok\":false,\"message\":\"Enrollment running. Try again later.\"}");
      return;
    }
 
    uint8_t p = finger.deleteModel(id);
 
    smartDelay(150);
 
    uint8_t verify = finger.loadModel(id);
 
    if (p == FINGERPRINT_OK && verify != FINGERPRINT_OK) {
      deleteProfile(id);
      clearVoterVoteState(id);
 
      addLog("DELETE", id, "Fingerprint deleted from sensor memory");
 
      refreshTemplateCountSerialOnly();
      rebuildRegisteredCache();
      nextEnrollID = findNextFreeID(1);
 
      server.send(200, "application/json", "{\"ok\":true,\"message\":\"Fingerprint deleted from sensor memory\"}");
    }
    else if (p == FINGERPRINT_OK && verify == FINGERPRINT_OK) {
      addLog("ERROR", id, "Delete command sent but fingerprint still exists");
      server.send(500, "application/json", "{\"ok\":false,\"message\":\"Delete command sent but fingerprint still exists in sensor\"}");
    }
    else {
      addLog("ERROR", id, "Delete failed");
      server.send(500, "application/json", "{\"ok\":false,\"message\":\"Delete failed. Fingerprint may not exist or sensor rejected command\"}");
    }
  });
 
  server.on("/api/export", HTTP_GET, []() {
    String csv = "time,event,fingerprint_id,name,college_id,role,department,candidate,message
";
 
    for (int i = 0; i < logCount; i++) {
      AttendanceLog l = getLogByDisplayIndex(i);
 
      csv += "\"" + l.timeText + "\",";
      csv += "\"" + l.event + "\",";
      csv += String(l.id) + ",";
      csv += "\"" + l.name + "\",";
      csv += "\"" + l.collegeId + "\",";
      csv += "\"" + l.role + "\",";
      csv += "\"" + l.department + "\",";
      csv += "\"" + l.candidate + "\",";
      csv += "\"" + l.message + "\"
";
    }
 
    server.sendHeader("Content-Disposition", "attachment; filename=vote_logs.csv");
    server.send(200, "text/csv", csv);
  });
 
  server.on("/api/reset-votes", HTTP_POST, []() {
    if (isEnrolling || isSelectingCandidate) {
      server.send(409, "application/json", "{\"ok\":false,\"message\":\"Cannot reset while enrollment or voting is active\"}");
      return;
    }
 
    resetAllVotes();
 
    server.send(200, "application/json", "{\"ok\":true,\"message\":\"Election reset successfully\"}");
  });
 
  server.begin();
  Serial.println("Dashboard server started.");
}
 
// =====================================================
// PROFILE STORAGE FUNCTIONS
// =====================================================
String profileKey(uint16_t id, const char* field) {
  return String(id) + "_" + String(field);
}
 
void saveProfile(uint16_t id, PersonProfile p) {
  prefs.putString(profileKey(id, "name").c_str(), p.name);
  prefs.putString(profileKey(id, "cid").c_str(), p.collegeId);
  prefs.putString(profileKey(id, "role").c_str(), p.role);
  prefs.putString(profileKey(id, "dept").c_str(), p.department);
  prefs.putString(profileKey(id, "note").c_str(), p.note);
}
 
PersonProfile loadProfile(uint16_t id) {
  PersonProfile p;
 
  p.name = prefs.getString(profileKey(id, "name").c_str(), "");
  p.collegeId = prefs.getString(profileKey(id, "cid").c_str(), "");
  p.role = prefs.getString(profileKey(id, "role").c_str(), "");
  p.department = prefs.getString(profileKey(id, "dept").c_str(), "");
  p.note = prefs.getString(profileKey(id, "note").c_str(), "");
 
  return p;
}
 
void deleteProfile(uint16_t id) {
  prefs.remove(profileKey(id, "name").c_str());
  prefs.remove(profileKey(id, "cid").c_str());
  prefs.remove(profileKey(id, "role").c_str());
  prefs.remove(profileKey(id, "dept").c_str());
  prefs.remove(profileKey(id, "note").c_str());
  prefs.remove(profileKey(id, "voted").c_str());
}
 
// =====================================================
// LOG + TIME FUNCTIONS
// =====================================================
String uptimeText() {
  return currentDateTimeText();
}
 
String currentDateTimeText() {
  struct tm timeinfo;
 
  if (getLocalTime(&timeinfo, 1000)) {
    char buf[24];
    strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &timeinfo);
    return String(buf);
  }
 
  unsigned long total = millis() / 1000;
  unsigned long h = total / 3600;
  unsigned long m = (total % 3600) / 60;
  unsigned long s = total % 60;
 
  char buf[24];
  snprintf(buf, sizeof(buf), "UPTIME %02lu:%02lu:%02lu", h, m, s);
 
  return String(buf);
}
 
String jsonEscape(String s) {
  s.replace("\", "\\");
  s.replace("\"", "\\"");
  s.replace("
", "\n");
  s.replace("
", "");
 
  return s;
}
 
String firstNameOnly(String fullName) {
  fullName.trim();
 
  if (fullName.length() == 0) {
    return "";
  }
 
  int spaceIndex = fullName.indexOf(' ');
 
  if (spaceIndex > 0) {
    return fullName.substring(0, spaceIndex);
  }
 
  return fullName;
}
 
void addLog(String event, int id, String message, String candidate) {
  logs[logWriteIndex].timeText = uptimeText();
  logs[logWriteIndex].event = event;
  logs[logWriteIndex].id = id;
  logs[logWriteIndex].message = message;
  logs[logWriteIndex].candidate = candidate;
 
  logs[logWriteIndex].name = "";
  logs[logWriteIndex].collegeId = "";
  logs[logWriteIndex].role = "";
  logs[logWriteIndex].department = "";
  logs[logWriteIndex].note = "";
 
  if (id >= 1 && id <= MAX_FINGERPRINTS) {
    PersonProfile p = loadProfile(id);
 
    logs[logWriteIndex].name = p.name;
    logs[logWriteIndex].collegeId = p.collegeId;
    logs[logWriteIndex].role = p.role;
    logs[logWriteIndex].department = p.department;
    logs[logWriteIndex].note = p.note;
  }
 
  logWriteIndex = (logWriteIndex + 1) % MAX_LOGS;
 
  if (logCount < MAX_LOGS) {
    logCount++;
  }
 
  Serial.print("[LOG] ");
  Serial.print(event);
  Serial.print(" ID=");
  Serial.print(id);
  Serial.print(" ");
  Serial.println(message);
}
 
AttendanceLog getLogByDisplayIndex(int i) {
  int start = (logWriteIndex - logCount + MAX_LOGS) % MAX_LOGS;
  int idx = (start + i) % MAX_LOGS;
 
  return logs[idx];
}
 
// =====================================================
// OLED DISPLAY FUNCTIONS
// =====================================================
String clipText(String text, uint8_t maxChars) {
  text.trim();
 
  if (text.length() <= maxChars) {
    return text;
  }
 
  if (maxChars <= 1) {
    return text.substring(0, maxChars);
  }
 
  return text.substring(0, maxChars - 1) + ".";
}
 
void drawCenteredText(String text, int y, uint8_t textSize) {
  text.trim();
 
  uint8_t maxChars = (textSize == 2) ? 10 : 21;
 
  text = clipText(text, maxChars);
 
  int charWidth = 6 * textSize;
  int textWidth = text.length() * charWidth;
  int x = (SCREEN_WIDTH - textWidth) / 2;
 
  if (x < 0) {
    x = 0;
  }
 
  display.setTextSize(textSize);
  display.setTextColor(SSD1306_WHITE, SSD1306_BLACK);
  display.setCursor(x, y);
  display.print(text);
}
 
void drawWiFiIcon(bool connected) {
  int x = 113;
  int y = 2;
 
  if (connected) {
    display.drawCircle(x + 7, y + 11, 1, SSD1306_WHITE);
    display.drawLine(x + 2, y + 8, x + 7, y + 4, SSD1306_WHITE);
    display.drawLine(x + 7, y + 4, x + 12, y + 8, SSD1306_WHITE);
    display.drawLine(x + 4, y + 10, x + 7, y + 8, SSD1306_WHITE);
    display.drawLine(x + 7, y + 8, x + 10, y + 10, SSD1306_WHITE);
  } else {
    display.drawRect(x + 1, y + 3, 12, 9, SSD1306_WHITE);
    display.drawLine(x, y + 2, x + 14, y + 13, SSD1306_WHITE);
    display.drawLine(x + 14, y + 2, x, y + 13, SSD1306_WHITE);
  }
}
 
void drawTitleBar(String title) {
  display.fillRect(0, 0, 128, 16, SSD1306_BLACK);
 
  String shortTitle = clipText(title, 16);
  int textWidth = shortTitle.length() * 6;
  int x = (112 - textWidth) / 2;
 
  if (x < 0) {
    x = 0;
  }
 
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE, SSD1306_BLACK);
  display.setCursor(x, 2);
  display.print(shortTitle);
 
  drawWiFiIcon(WiFi.status() == WL_CONNECTED);
 
  display.drawLine(0, 15, 127, 15, SSD1306_WHITE);
}
 
void oledScreen(String title, String line1, String line2, String line3) {
  display.clearDisplay();
  display.setTextWrap(false);
 
  drawTitleBar(title);
 
  if (line1.length() > 0) {
    drawCenteredText(line1, 22, 1);
  }
 
  if (line2.length() > 0) {
    drawCenteredText(line2, 36, 1);
  }
 
  if (line3.length() > 0) {
    drawCenteredText(line3, 50, 1);
  }
 
  display.display();
}
 
void oledBig(String title, String line1, String line2) {
  display.clearDisplay();
  display.setTextWrap(false);
 
  drawTitleBar(title);
 
  if (line1.length() > 0) {
    drawCenteredText(line1, 24, 1);
  }
 
  if (line2.length() > 0) {
    drawCenteredText(line2, 42, 1);
  }
 
  display.display();
}
 
void drawMiniFingerprint(int x, int y) {
  display.drawCircle(x + 18, y + 18, 17, SSD1306_WHITE);
  display.drawCircle(x + 18, y + 18, 12, SSD1306_WHITE);
  display.drawCircle(x + 18, y + 18, 7, SSD1306_WHITE);
 
  display.fillRect(x - 2, y + 28, 42, 14, SSD1306_BLACK);
  display.fillRect(x - 2, y - 2, 7, 44, SSD1306_BLACK);
  display.fillRect(x + 32, y - 2, 8, 44, SSD1306_BLACK);
 
  display.drawLine(x + 18, y + 18, x + 18, y + 36, SSD1306_WHITE);
  display.drawLine(x + 12, y + 21, x + 10, y + 35, SSD1306_WHITE);
  display.drawLine(x + 24, y + 21, x + 28, y + 34, SSD1306_WHITE);
}
 
void drawLoadingDots(int x, int y, uint8_t frame) {
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE, SSD1306_BLACK);
  display.setCursor(x, y);
  display.print("Please wait");
 
  for (uint8_t i = 0; i < frame % 4; i++) {
    display.print(".");
  }
}
 
void drawMovingBar(int y, uint8_t frame) {
  int boxX = 12;
  int boxW = 104;
  int fillW = 22;
  int pos = (frame * 8) % (boxW - fillW);
 
  display.drawRect(boxX, y, boxW, 8, SSD1306_WHITE);
  display.fillRect(boxX + 2 + pos, y + 2, fillW, 4, SSD1306_WHITE);
}
 
void drawProgress(uint8_t percent) {
  if (percent > 100) {
    percent = 100;
  }
 
  int barX = 10;
  int barY = 52;
  int barW = 108;
  int barH = 9;
 
  display.drawRect(barX, barY, barW, barH, SSD1306_WHITE);
 
  int fillW = map(percent, 0, 100, 0, barW - 4);
 
  display.fillRect(barX + 2, barY + 2, fillW, barH - 4, SSD1306_WHITE);
 
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE, SSD1306_BLACK);
  display.setCursor(52, 41);
  display.print(percent);
  display.print("%");
}
 
void oledScanScreen() {
  static uint8_t idleFrame = 0;
  idleFrame++;
 
  display.clearDisplay();
  display.setTextWrap(false);
 
  drawTitleBar("SCAN TO VOTE");
  drawMiniFingerprint(8, 22);
 
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE, SSD1306_BLACK);
 
  display.setCursor(56, 24);
  display.print("Scan to vote");
 
  display.setCursor(56, 38);
  display.print("Waiting");
 
  for (uint8_t i = 0; i < idleFrame % 4; i++) {
    display.print(".");
  }
 
  int r = 3 + (idleFrame % 4);
 
  display.drawCircle(103, 50, r, SSD1306_WHITE);
 
  display.display();
}
 
void oledScanAnimation(String title, String line1, uint16_t durationMs) {
  unsigned long startTime = millis();
  uint8_t frame = 0;
 
  while (millis() - startTime < durationMs) {
    server.handleClient();
 
    display.clearDisplay();
    display.setTextWrap(false);
 
    drawTitleBar(title);
    drawMiniFingerprint(8, 21);
 
    display.setTextSize(1);
    display.setTextColor(SSD1306_WHITE, SSD1306_BLACK);
 
    display.setCursor(56, 24);
    display.print(clipText(line1, 11));
 
    drawLoadingDots(56, 38, frame);
 
    int scanY = 22 + ((frame * 4) % 32);
 
    display.drawLine(8, scanY, 45, scanY, SSD1306_WHITE);
    display.drawLine(8, scanY + 1, 45, scanY + 1, SSD1306_WHITE);
 
    drawMovingBar(54, frame);
 
    display.display();
 
    frame++;
    delay(100);
  }
}
 
void oledProgressBar(String title, String line1, uint8_t percent) {
  display.clearDisplay();
  display.setTextWrap(false);
 
  drawTitleBar(title);
  drawCenteredText(line1, 24, 1);
  drawProgress(percent);
 
  display.display();
}
 
void oledSuccessAnimation(String line1, String line2) {
  for (int r = 3; r <= 14; r += 3) {
    server.handleClient();
 
    display.clearDisplay();
    display.setTextWrap(false);
 
    drawTitleBar("SUCCESS");
 
    display.drawCircle(64, 34, r, SSD1306_WHITE);
 
    if (r >= 9) {
      display.drawLine(56, 34, 62, 41, SSD1306_WHITE);
      display.drawLine(62, 41, 74, 27, SSD1306_WHITE);
      display.drawLine(56, 35, 62, 42, SSD1306_WHITE);
      display.drawLine(62, 42, 74, 28, SSD1306_WHITE);
    }
 
    display.display();
 
    smartDelay(120);
  }
 
  smartDelay(300);
 
  if (line2.length() > 0) {
    oledBig("SUCCESS", line1, line2);
  } else {
    oledBig("SUCCESS", line1);
  }
}
 
void oledErrorAnimation(String line1, String line2) {
  for (int i = 0; i < 3; i++) {
    server.handleClient();
 
    display.clearDisplay();
    display.setTextWrap(false);
 
    drawTitleBar("NOTICE");
 
    display.drawCircle(64, 34, 13, SSD1306_WHITE);
    display.drawLine(56, 26, 72, 42, SSD1306_WHITE);
    display.drawLine(72, 26, 56, 42, SSD1306_WHITE);
    display.drawLine(57, 26, 73, 42, SSD1306_WHITE);
    display.drawLine(73, 26, 57, 42, SSD1306_WHITE);
 
    display.display();
 
    smartDelay(180);
 
    display.clearDisplay();
    drawTitleBar("NOTICE");
    display.display();
 
    smartDelay(100);
  }
 
  if (line2.length() > 0) {
    oledBig("TRY AGAIN", line1, line2);
  } else {
    oledBig("TRY AGAIN", line1);
  }
}
 
// =====================================================
// BUTTON HANDLING
// =====================================================
bool buttonPressed(uint8_t pin, unsigned long &lastTime) {
  if (digitalRead(pin) == LOW) {
    if (millis() - lastTime > DEBOUNCE_MS) {
      lastTime = millis();
      return true;
    }
  }
 
  return false;
}
 
int readCandidateButton() {
  // Candidate buttons are ignored outside the post-scan voting window.
  if (!isSelectingCandidate || isEnrolling) {
    return -1;
  }
 
  for (int i = 0; i < NUM_CANDIDATES; i++) {
    if (buttonPressed(CAND_PINS[i], lastCandBtnTime[i])) {
      while (digitalRead(CAND_PINS[i]) == LOW) {
        server.handleClient();
        delay(10);
      }
 
      return i;
    }
  }
 
  return -1;
}
 
bool checkCancelDuringEnroll() {
  server.handleClient();
 
  if (cancelEnroll) {
    cancelEnroll = true;
 
    oledBig("CANCELLED", "Back to scan");
    smartDelay(800);
 
    return true;
  }
 
  return false;
}
 
// =====================================================
// SCAN FINGERPRINT - STABLE VERSION
// =====================================================
void scanFingerprint() {
  uint8_t p = finger.getImage();
 
  if (p == FINGERPRINT_NOFINGER) {
    if (millis() - lastIdleRefresh > 700) {
      lastIdleRefresh = millis();
      oledScanScreen();
    }
 
    return;
  }
 
  if (p != FINGERPRINT_OK) {
    addLog("ERROR", -1, "Could not read fingerprint");
    oledErrorAnimation("Could not read", "Place properly");
 
    smartDelay(800);
 
    waitForFingerRemove();
    oledScanScreen();
 
    return;
  }
 
  oledScreen("SCANNING", "Hold finger", "Reading...");
 
  p = finger.image2Tz(1);
 
  if (p != FINGERPRINT_OK) {
    addLog("ERROR", -1, "Fingerprint image not clear");
    oledErrorAnimation("Finger not clear", "Try again");
 
    smartDelay(800);
 
    waitForFingerRemove();
    oledScanScreen();
 
    return;
  }
 
  refreshTemplateCountSerialOnly();
 
  if (finger.templateCount == 0) {
    addLog("NOT FOUND", -1, "No fingerprints stored");
    oledBig("NOT FOUND", "No saved print");
 
    smartDelay(1600);
 
    waitForFingerRemove();
    oledScanScreen();
 
    return;
  }
 
  oledScreen("SEARCHING", "Please wait", "Checking...");
 
  // IMPORTANT:
  // fingerSearch(1) is more reliable for your R503.
  // If it fails, fastSearch is tried as fallback.
  p = finger.fingerSearch(1);
 
  if (p != FINGERPRINT_OK) {
    p = finger.fingerFastSearch();
  }
 
  if (p == FINGERPRINT_OK) {
    int id = finger.fingerID;
 
    PersonProfile profile = loadProfile(id);
 
    String fullName = profile.name;
    String displayName = firstNameOnly(fullName);
 
    if (displayName.length() == 0) {
      displayName = "ID " + String(id);
    }
 
    String logName = fullName;
 
    if (logName.length() == 0) {
      logName = "ID " + String(id);
    }
 
    waitForFingerRemove();
 
    if (id >= 1 && id <= MAX_FINGERPRINTS && hasVoted[id]) {
      addLog("ALREADY VOTED", id, logName + " already voted");
      oledErrorAnimation("Already Voted", displayName);
 
      smartDelay(1500);
      oledScanScreen();
 
      return;
    }
 
    if (id >= 1 && id <= MAX_FINGERPRINTS) {
      pendingVoterId = id;
      isSelectingCandidate = true;
      voteWindowStart = millis();
 
      addLog("VOTE PENDING", id, logName + " select candidate");
 
      oledScreen("VOTING", "Welcome " + clipText(displayName, 14), "Press Candidate Btn");
 
      return;
    }
 
    addLog("MATCH", id, logName + " matched");
    oledSuccessAnimation("Matched", displayName);
 
    smartDelay(1500);
    oledScanScreen();
 
    return;
  }
 
  if (p == FINGERPRINT_NOTFOUND) {
    addLog("NOT FOUND", -1, "No matching fingerprint");
    oledErrorAnimation("Not found", "No match");
 
    smartDelay(1200);
 
    waitForFingerRemove();
    oledScanScreen();
 
    return;
  }
 
  addLog("ERROR", -1, "Fingerprint search failed");
  oledErrorAnimation("Search failed", "Try again");
 
  smartDelay(1200);
 
  waitForFingerRemove();
  oledScanScreen();
}
 
// =====================================================
// ENROLL FINGERPRINT
// =====================================================
uint8_t enrollFingerprint(uint16_t id) {
  int p = -1;
 
  oledProgressBar("ENROLLING", "Place finger", 10);
 
  while (p != FINGERPRINT_OK) {
    server.handleClient();
 
    if (checkCancelDuringEnroll()) {
      return 255;
    }
 
    p = finger.getImage();
 
    if (p == FINGERPRINT_OK) {
      oledProgressBar("ENROLLING", "Checking", 20);
      smartDelay(500);
      break;
    }
    else if (p == FINGERPRINT_NOFINGER) {
      // waiting
    }
    else {
      oledErrorAnimation("Could not read", "Try again");
      return p;
    }
 
    delay(100);
  }
 
  if (checkCancelDuringEnroll()) {
    return 255;
  }
 
  p = finger.image2Tz(1);
 
  if (p != FINGERPRINT_OK) {
    oledErrorAnimation("Finger not clear", "Try again");
    return p;
  }
 
  refreshTemplateCountSerialOnly();
 
  if (finger.templateCount > 0) {
    oledProgressBar("CHECKING", "Existing", 30);
 
    uint8_t searchResult = finger.fingerSearch(1);
 
    if (searchResult != FINGERPRINT_OK) {
      searchResult = finger.fingerFastSearch();
    }
 
    if (searchResult == FINGERPRINT_OK) {
      duplicateFound = true;
 
      PersonProfile existingProfile = loadProfile(finger.fingerID);
      String existingName = firstNameOnly(existingProfile.name);
 
      if (existingName.length() == 0) {
        existingName = "ID " + String(finger.fingerID);
      }
 
      addLog("DUPLICATE", finger.fingerID, "Already enrolled");
 
      oledBig("ALREADY", existingName);
 
      smartDelay(2500);
 
      waitForFingerRemove();
 
      return 255;
    }
  }
 
  oledProgressBar("ENROLLING", "First scan OK", 40);
  smartDelay(600);
 
  oledProgressBar("ENROLLING", "Remove finger", 50);
  smartDelay(1200);
 
  while (finger.getImage() != FINGERPRINT_NOFINGER) {
    server.handleClient();
 
    if (checkCancelDuringEnroll()) {
      return 255;
    }
 
    delay(100);
  }
 
  smartDelay(500);
 
  p = -1;
 
  oledProgressBar("ENROLLING", "Same finger", 65);
 
  while (p != FINGERPRINT_OK) {
    server.handleClient();
 
    if (checkCancelDuringEnroll()) {
      return 255;
    }
 
    p = finger.getImage();
 
    if (p == FINGERPRINT_OK) {
      oledProgressBar("ENROLLING", "Second OK", 75);
      smartDelay(500);
      break;
    }
    else if (p == FINGERPRINT_NOFINGER) {
      // waiting
    }
    else {
      oledErrorAnimation("Could not read", "Try again");
      return p;
    }
 
    delay(100);
  }
 
  if (checkCancelDuringEnroll()) {
    return 255;
  }
 
  p = finger.image2Tz(2);
 
  if (p != FINGERPRINT_OK) {
    oledErrorAnimation("Finger not clear", "Try again");
    return p;
  }
 
  oledProgressBar("PROCESSING", "Creating", 85);
 
  p = finger.createModel();
 
  if (p == FINGERPRINT_OK) {
    // model created
  }
  else if (p == FINGERPRINT_ENROLLMISMATCH) {
    oledErrorAnimation("Mismatch", "Use same finger");
    return p;
  }
  else {
    oledErrorAnimation("Could not save", "Try again");
    return p;
  }
 
  oledProgressBar("SAVING", "Please wait", 95);
 
  p = finger.storeModel(id);
 
  if (p == FINGERPRINT_OK) {
    oledProgressBar("SAVING", "Complete", 100);
    smartDelay(500);
 
    return FINGERPRINT_OK;
  }
  else {
    oledErrorAnimation("Memory problem", "Not saved");
    return p;
  }
}
 
// =====================================================
// VOTING FUNCTIONS
// =====================================================
void loadVoteState() {
  for (int i = 1; i <= MAX_FINGERPRINTS; i++) {
    hasVoted[i] = prefs.getBool(profileKey(i, "voted").c_str(), false);
  }
 
  candidateVotes[0] = prefs.getUInt("votes_c1", 0);
  candidateVotes[1] = prefs.getUInt("votes_c2", 0);
  candidateVotes[2] = prefs.getUInt("votes_c3", 0);
  candidateVotes[3] = prefs.getUInt("votes_c4", 0);
}
 
void saveVoteForVoter(uint16_t id) {
  prefs.putBool(profileKey(id, "voted").c_str(), true);
}
 
void saveCandidateVotes() {
  prefs.putUInt("votes_c1", candidateVotes[0]);
  prefs.putUInt("votes_c2", candidateVotes[1]);
  prefs.putUInt("votes_c3", candidateVotes[2]);
  prefs.putUInt("votes_c4", candidateVotes[3]);
}
 
void clearVoterVoteState(uint16_t id) {
  if (id >= 1 && id <= MAX_FINGERPRINTS) {
    hasVoted[id] = false;
  }
 
  prefs.remove(profileKey(id, "voted").c_str());
}
 
void resetAllVotes() {
  for (int i = 1; i <= MAX_FINGERPRINTS; i++) {
    clearVoterVoteState(i);
  }
 
  for (int i = 0; i < NUM_CANDIDATES; i++) {
    candidateVotes[i] = 0;
  }
 
  saveCandidateVotes();
  addLog("RESET", -1, "Election reset - all votes cleared");
}
 
int getTotalVotes() {
  int total = 0;
 
  for (int i = 0; i < NUM_CANDIDATES; i++) {
    total += candidateVotes[i];
  }
 
  return total;
}
 
void castVote(int voterId, int candidateIndex) {
  if (voterId < 1 || voterId > MAX_FINGERPRINTS) {
    return;
  }
 
  if (candidateIndex < 0 || candidateIndex >= NUM_CANDIDATES) {
    return;
  }
 
  candidateVotes[candidateIndex]++;
  hasVoted[voterId] = true;
 
  saveVoteForVoter(voterId);
  saveCandidateVotes();
 
  String candName = String(CANDIDATE_NAMES[candidateIndex]);
 
  addLog("VOTE", voterId, "Voted for " + candName, candName);
 
  oledSuccessAnimation("Vote Cast", candName);
 
  smartDelay(1200);
}
 
void handleCandidateSelection() {
  server.handleClient();
 
  if (millis() - voteWindowStart > VOTE_TIMEOUT_MS) {
    int voterId = pendingVoterId;
 
    addLog("VOTE TIMEOUT", voterId, "Candidate selection timed out");
 
    isSelectingCandidate = false;
    pendingVoterId = -1;
 
    oledErrorAnimation("Time Expired", "Scan again");
    smartDelay(1200);
    oledScanScreen();
 
    return;
  }
 
  int candidateIndex = readCandidateButton();
 
  if (candidateIndex < 0) {
    return;
  }
 
  int voterId = pendingVoterId;
 
  isSelectingCandidate = false;
  pendingVoterId = -1;
 
  castVote(voterId, candidateIndex);
  oledScanScreen();
}
 
// =====================================================
// HELPERS
// =====================================================
void waitForFingerRemove() {
  uint8_t p = finger.getImage();
 
  while (p != FINGERPRINT_NOFINGER) {
    server.handleClient();
    delay(100);
    p = finger.getImage();
  }
}
 
uint16_t findNextFreeID(uint16_t startID) {
  if (startID < 1) {
    startID = 1;
  }
 
  for (uint16_t id = startID; id <= MAX_FINGERPRINTS; id++) {
    server.handleClient();
 
    uint8_t p = finger.loadModel(id);
 
    if (p != FINGERPRINT_OK) {
      return id;
    }
  }
 
  return MAX_FINGERPRINTS;
}
 
void refreshTemplateCountSerialOnly() {
  uint8_t p = finger.getTemplateCount();
 
  if (p == FINGERPRINT_OK) {
    cachedTemplateCount = finger.templateCount;
 
    Serial.print("Stored fingerprints: ");
    Serial.println(cachedTemplateCount);
  } else {
    Serial.println("Could not read template count.");
  }
 
  delay(20);
}
 
void refreshTemplateCountCache() {
  uint8_t p = finger.getTemplateCount();
 
  if (p == FINGERPRINT_OK) {
    cachedTemplateCount = finger.templateCount;
  }
 
  delay(20);
}
 
void rebuildRegisteredCache() {
  String json = "{\"fingerprints\":[";
  bool first = true;
 
  for (uint16_t id = 1; id <= MAX_FINGERPRINTS; id++) {
    server.handleClient();
 
    uint8_t p = finger.loadModel(id);
 
    if (p == FINGERPRINT_OK) {
      PersonProfile profile = loadProfile(id);
 
      if (!first) {
        json += ",";
      }
 
      json += "{";
      json += "\"id\":" + String(id) + ",";
      json += "\"name\":\"" + jsonEscape(profile.name) + "\",";
      json += "\"collegeId\":\"" + jsonEscape(profile.collegeId) + "\",";
      json += "\"role\":\"" + jsonEscape(profile.role) + "\",";
      json += "\"department\":\"" + jsonEscape(profile.department) + "\",";
      json += "\"dob\":\"" + jsonEscape(profile.note) + "\"";
      json += "}";
 
      first = false;
    }
  }
 
  json += "]}";
 
  cachedRegisteredJson = json;
}
 
void setMatchingSecuritySerialOnly() {
  Serial.print("Setting security level to ");
  Serial.println(MATCH_SECURITY_LEVEL);
 
  uint8_t p = finger.setSecurityLevel(MATCH_SECURITY_LEVEL);
 
  if (p == FINGERPRINT_OK) {
    Serial.println("Security level updated.");
  } else {
    Serial.println("Could not update security level.");
  }
 
  delay(100);
 
  finger.getParameters();
 
  Serial.print("Current security level: ");
  Serial.println(finger.security_level);
}
 
void printSensorParameters() {
  finger.getParameters();
 
  Serial.println();
  Serial.println("Sensor parameters:");
 
  Serial.print("Status: 0x");
  Serial.println(finger.status_reg, HEX);
 
  Serial.print("System ID: 0x");
  Serial.println(finger.system_id, HEX);
 
  Serial.print("Capacity: ");
  Serial.println(finger.capacity);
 
  Serial.print("Security level: ");
  Serial.println(finger.security_level);
 
  Serial.print("Device address: ");
  Serial.println(finger.device_addr, HEX);
 
  Serial.print("Packet length: ");
  Serial.println(finger.packet_len);
 
  Serial.print("Baud rate: ");
  Serial.println(finger.baud_rate);
 
  Serial.println();
}

dashboard.h code

Here is the header file for the project. Create another tab in the Arduino IDE editor window and paste the following code, which contains the complete live voting dashboard webpage.

C++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
#ifndef DASHBOARD_H
#define DASHBOARD_H
 
#include <Arduino.h>
 
const char DASHBOARD_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Fingerprint-Based Electronic Voting Machine</title>
<style>
:root{--primary:#2563eb;--primary-light:#38bdf8;--primary-dark:#1e3a8a;--accent:#8b5cf6;--bg:#eef2ff;--card:rgba(255,255,255,.94);--text:#0f172a;--muted:#64748b;--line:rgba(148,163,184,.22);--green:#10b981;--red:#ef4444;--amber:#f59e0b;--info:#0ea5e9;--shadow:0 10px 40px rgba(15,23,42,.07);--shadow-lg:0 20px 50px rgba(37,99,235,.12);--glow-inset:0 0 0 1px rgba(255,255,255,.65) inset;--page-margin:10%;--shell-radius:24px;--font:'Segoe UI',system-ui,-apple-system,BlinkMacSystemFont,Roboto,'Helvetica Neue',Arial,sans-serif}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{margin:0;font-family:var(--font);background:#030712;color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
body::before{content:"";position:fixed;inset:0;background:radial-gradient(ellipse 80% 60% at 10% 15%,rgba(37,99,235,.45),transparent 50%),radial-gradient(ellipse 70% 50% at 90% 8%,rgba(34,211,238,.35),transparent 45%),radial-gradient(ellipse 60% 40% at 50% 95%,rgba(139,92,246,.28),transparent 50%),linear-gradient(160deg,#030712 0%,#0f172a 40%,#172554 100%);z-index:-2}
body::after{content:"";position:fixed;inset:0;background:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");opacity:.4;pointer-events:none;z-index:-1}
.page-shell{padding:18px var(--page-margin);height:100vh;box-sizing:border-box}
.app{display:grid;grid-template-columns:272px 1fr;height:100%;overflow:hidden;border-radius:var(--shell-radius);box-shadow:0 30px 90px rgba(0,0,0,.45),0 0 0 1px rgba(255,255,255,.08),var(--glow-inset);background:rgba(255,255,255,.97);backdrop-filter:blur(24px) saturate(1.2);position:relative}
.app::before{content:"";position:absolute;inset:0;border-radius:inherit;background:linear-gradient(135deg,rgba(255,255,255,.4),transparent 40%);pointer-events:none;z-index:0}
.app>*{position:relative;z-index:1}
.sidebar{background:linear-gradient(165deg,#050a14 0%,#0c1222 40%,#111827 100%);color:#fff;padding:22px 18px;display:flex;flex-direction:column;height:100%;overflow:hidden;border-right:1px solid rgba(255,255,255,.06);position:relative}
.sidebar::before{content:"";position:absolute;top:0;left:0;right:0;height:4px;background:linear-gradient(90deg,#22d3ee,#2563eb,#7c3aed,#2563eb);background-size:200% 100%;animation:barShift 6s linear infinite}
@keyframes barShift{0%{background-position:0% 50%}100%{background-position:200% 50%}}
.brand{display:flex;align-items:center;gap:14px;margin-bottom:8px;padding:6px 4px}
.logo{width:54px;height:54px;border-radius:18px;background:linear-gradient(145deg,#38bdf8,#2563eb 50%,#4f46e5);display:grid;place-items:center;font-family:var(--font);font-size:.85rem;font-weight:700;letter-spacing:.02em;color:#fff;flex-shrink:0;box-shadow:0 12px 32px rgba(37,99,235,.5),inset 0 1px 0 rgba(255,255,255,.3);position:relative}
.logo::after{content:"";position:absolute;inset:-4px;border-radius:22px;border:2px solid rgba(56,189,248,.4);animation:logoRing 3s ease-in-out infinite}
@keyframes logoRing{0%,100%{opacity:.35;transform:scale(1)}50%{opacity:1;transform:scale(1.05)}}
.brand h1{font-family:var(--font);font-size:1.05rem;margin:0;line-height:1.2;font-weight:700;letter-spacing:0;color:#fff}
.brand p{margin:4px 0 0;color:#64748b;font-size:.72rem;line-height:1.4;font-weight:500;letter-spacing:.02em;text-transform:none}
.nav-label{font-size:.68rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:#475569;margin:18px 0 8px 12px}
.sidebar-nav{display:flex;flex-direction:column;gap:5px}
.sidebar-nav button{width:100%;display:flex;align-items:center;gap:11px;background:transparent;color:#94a3b8;border:1px solid transparent;border-radius:14px;padding:12px 14px;font-weight:600;font-size:.875rem;cursor:pointer;text-align:left;transition:all .28s cubic-bezier(.4,0,.2,1);position:relative;overflow:hidden}
.sidebar-nav button::before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);width:3px;height:0;background:linear-gradient(180deg,#38bdf8,#2563eb);border-radius:0 4px 4px 0;transition:height .28s ease}
.sidebar-nav button:hover{color:#f1f5f9;background:rgba(255,255,255,.05);border-color:rgba(255,255,255,.06);padding-left:16px}
.sidebar-nav button.active{color:#fff;background:linear-gradient(135deg,rgba(37,99,235,.35),rgba(56,189,248,.12));border-color:rgba(96,165,250,.2);box-shadow:0 8px 28px rgba(37,99,235,.25),inset 0 1px 0 rgba(255,255,255,.08)}
.sidebar-nav button.active::before{height:60%}
.device-box{margin-top:auto;padding:16px 18px;border-radius:18px;background:linear-gradient(145deg,rgba(255,255,255,.07),rgba(255,255,255,.02));border:1px solid rgba(255,255,255,.08);color:#94a3b8;font-size:.74rem;line-height:1.8;backdrop-filter:blur(12px);box-shadow:inset 0 1px 0 rgba(255,255,255,.06)}
.device-box b{color:#e2e8f0;font-size:.78rem;letter-spacing:.06em;text-transform:uppercase;font-weight:700}
.dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--red);box-shadow:0 0 14px rgba(239,68,68,.7);vertical-align:middle;margin-left:5px;animation:dotPulse 2s infinite}
.dot.ok{background:var(--green);box-shadow:0 0 14px rgba(16,185,129,.7)}
@keyframes dotPulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.5;transform:scale(.85)}}
.main{padding:22px 28px 24px;min-width:0;overflow:hidden;display:flex;flex-direction:column;height:100%;background:linear-gradient(180deg,#fafbff 0%,#f1f5f9 100%);position:relative}
.main::before{content:"";position:absolute;inset:0;background-image:radial-gradient(circle at 1px 1px,rgba(148,163,184,.12) 1px,transparent 0);background-size:24px 24px;pointer-events:none;opacity:.5}
.main>*{position:relative;z-index:1}
.topbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:18px;flex-wrap:wrap;flex-shrink:0;padding:14px 18px;background:rgba(255,255,255,.72);border:1px solid var(--line);border-radius:18px;box-shadow:var(--shadow);backdrop-filter:blur(12px)}
.topbar h2{margin:0;font-family:var(--font);font-size:1.35rem;letter-spacing:0;font-weight:700;color:var(--text)}
.topbar p{margin:5px 0 0;color:var(--muted);font-size:.84rem;max-width:640px;line-height:1.45;font-weight:500}
.live-pill{display:inline-flex;align-items:center;gap:6px;font-size:.7rem;font-weight:600;text-transform:none;letter-spacing:0;color:#059669;background:linear-gradient(135deg,#ecfdf5,#d1fae5);padding:4px 10px;border-radius:999px;margin-bottom:6px;border:1px solid #a7f3d0}
.live-dot{width:6px;height:6px;border-radius:50%;background:#10b981;box-shadow:0 0 10px #10b981;animation:dotPulse 1.5s infinite}
.topbar-actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.topbar-actions .btn{padding:10px 16px;font-size:.8rem;border-radius:999px}
.page{display:none;flex:1;min-height:0;overflow-y:auto}
.page.active{display:block}
.page.dash-page.active{display:flex;flex-direction:column;overflow:hidden}
.main.dash-view .topbar{margin-bottom:12px;padding-bottom:10px}
.main.dash-view .topbar h2{font-size:1.25rem}
.main.dash-view .topbar p{display:none}
.main.dash-view{padding:16px 22px 18px}
.card{background:var(--card);border:1px solid var(--line);border-radius:20px;box-shadow:var(--shadow);overflow:hidden;height:100%;display:flex;flex-direction:column;backdrop-filter:blur(16px);transition:transform .3s ease,box-shadow .3s ease}
.panel-card:hover{box-shadow:var(--shadow-lg);transform:translateY(-2px)}
.dash-page .panel-card:hover{transform:none}
.card-header{background:linear-gradient(125deg,#1d4ed8 0%,#2563eb 35%,#0891b2 70%,#06b6d4 100%);color:#fff;padding:14px 20px;font-weight:700;border:none;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;position:relative;overflow:hidden}
.card-header::before{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.08),transparent);transform:translateX(-100%);animation:headerShine 4s ease-in-out infinite}
@keyframes headerShine{0%,100%{transform:translateX(-100%)}50%{transform:translateX(100%)}}
.card-header::after{content:"";position:absolute;top:-50%;right:-5%;width:140px;height:140px;background:radial-gradient(circle,rgba(255,255,255,.2),transparent 65%);pointer-events:none}
.card-header-title{white-space:nowrap;position:relative;z-index:1;letter-spacing:0;font-family:var(--font);font-size:.95rem;font-weight:600}
.card-header-meta{font-size:.72rem;font-weight:600;white-space:nowrap;position:relative;z-index:1;background:rgba(255,255,255,.15);padding:5px 12px;border-radius:999px;border:1px solid rgba(255,255,255,.2);backdrop-filter:blur(4px)}
.card-body{padding:20px;flex:1}
.stat-card{text-align:left;padding:18px;position:relative;overflow:hidden;border:1px solid var(--line)!important;background:linear-gradient(160deg,rgba(255,255,255,.98),rgba(248,250,252,.9))!important}
.stat-card::before{content:"";position:absolute;top:-40px;right:-40px;width:110px;height:110px;border-radius:50%;background:radial-gradient(circle,rgba(37,99,235,.1),transparent 70%);pointer-events:none}
.stat-card .stat-icon{width:44px;height:44px;border-radius:14px;display:grid;place-items:center;font-size:1.2rem;margin-bottom:12px;background:#f8fafc;box-shadow:0 6px 16px rgba(15,23,42,.08),inset 0 1px 0 rgba(255,255,255,.8)}
.stat-card .value{word-break:break-word;font-size:1.9rem;font-weight:700;color:var(--text);line-height:1;font-variant-numeric:tabular-nums;letter-spacing:0}
.stat-card .label{font-size:.68rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);font-weight:600;margin-bottom:8px}
.dash-stats .stat-card{text-align:center;padding:16px 14px;transition:transform .25s cubic-bezier(.4,0,.2,1),box-shadow .25s ease}
.dash-stats .stat-card:hover{transform:translateY(-4px);box-shadow:var(--shadow-lg)}
.dash-stats .stat-card .stat-icon{margin:0 auto 12px}
.dash-stats .stat-card.stat-accent-1 .stat-icon{background:linear-gradient(145deg,#dbeafe,#93c5fd);color:#1e40af;box-shadow:0 6px 20px rgba(37,99,235,.2)}
.dash-stats .stat-card.stat-accent-2 .stat-icon{background:linear-gradient(145deg,#d1fae5,#6ee7b7);color:#047857;box-shadow:0 6px 20px rgba(16,185,129,.2)}
.dash-stats .stat-card.stat-accent-3 .stat-icon{background:linear-gradient(145deg,#fef3c7,#fcd34d);color:#b45309;box-shadow:0 6px 20px rgba(245,158,11,.2)}
.dash-stats .stat-card.stat-accent-4 .stat-icon{background:linear-gradient(145deg,#ede9fe,#c4b5fd);color:#6d28d9;box-shadow:0 6px 20px rgba(139,92,246,.2)}
.dash-stats .stat-card{padding:16px 14px;border:none!important;box-shadow:var(--shadow)!important}
.dash-stats .stat-card.stat-accent-1{box-shadow:0 8px 30px rgba(59,130,246,.12)!important}.dash-stats .stat-card.stat-accent-2{box-shadow:0 8px 30px rgba(16,185,129,.1)!important}.dash-stats .stat-card.stat-accent-3{box-shadow:0 8px 30px rgba(245,158,11,.1)!important}.dash-stats .stat-card.stat-accent-4{box-shadow:0 8px 30px rgba(139,92,246,.1)!important}
.dash-stats .stat-card .value{font-size:1.6rem!important}
.dash-stats .stat-card .label{font-size:.62rem;margin-bottom:4px}
.winner-banner{background:linear-gradient(135deg,rgba(236,253,245,.98),rgba(167,243,208,.85));border:1px solid rgba(110,231,183,.6);border-radius:20px;padding:15px 22px;margin-bottom:16px;color:#064e3b;font-weight:700;font-size:.9rem;line-height:1.45;flex-shrink:0;display:flex;align-items:center;gap:14px;box-shadow:0 12px 36px rgba(16,185,129,.18),inset 0 1px 0 rgba(255,255,255,.6);position:relative;overflow:hidden}
.winner-banner::before{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.35),transparent);transform:translateX(-100%);animation:headerShine 5s ease-in-out infinite;pointer-events:none}
.winner-banner.empty{background:rgba(255,255,255,.85);border-color:var(--line);color:var(--muted);box-shadow:var(--shadow);font-weight:600}
.winner-banner.empty::before{display:none}
.winner-banner .banner-icon{font-size:1.5rem;flex-shrink:0;filter:drop-shadow(0 2px 6px rgba(16,185,129,.3));position:relative;z-index:1}
.winner-banner>span{position:relative;z-index:1}
.dash-page .row{gap:14px;margin-bottom:14px}
.dash-stats{margin-bottom:12px!important}
.dash-stats .stat-card .sub{display:none}
.dash-split{display:grid;grid-template-columns:3fr 2fr;gap:16px;flex:1;min-height:0}
.dash-split>.card{min-height:0}
.dash-split .dash-votes .card-body{display:flex;flex-direction:column;min-height:0;padding:16px 18px;overflow:hidden;background:linear-gradient(180deg,#fafbff,#f8fafc)}
.dash-split .card-body{padding:16px 18px;overflow-y:auto}
.dash-split .card-header{padding:12px 18px;font-size:.92rem}
#voteDistributionPanel,#liveResultsPanel{display:flex;flex-direction:column;gap:12px;flex:1;min-height:0}
.candidate-row{padding:15px 18px;border-radius:16px;border:1px solid var(--line);background:rgba(255,255,255,.88);box-shadow:0 4px 16px rgba(15,23,42,.04);transition:all .28s cubic-bezier(.4,0,.2,1);border-left:5px solid transparent;backdrop-filter:blur(8px)}
.candidate-row:hover{transform:translateY(-2px);box-shadow:0 12px 32px rgba(15,23,42,.1)}
.candidate-row.cand-0{border-left-color:#3b82f6}.candidate-row.cand-1{border-left-color:#10b981}.candidate-row.cand-2{border-left-color:#06b6d4}.candidate-row.cand-3{border-left-color:#f59e0b}
.candidate-row.leading{background:linear-gradient(135deg,rgba(240,253,244,.98),rgba(209,250,229,.6));border-color:rgba(134,239,172,.8);box-shadow:0 12px 36px rgba(16,185,129,.18)}
.candidate-main{display:flex;align-items:center;gap:12px;margin-bottom:10px}
.cand-badge{width:36px;height:36px;border-radius:12px;display:grid;place-items:center;font-size:.82rem;font-weight:800;color:#fff;flex-shrink:0;box-shadow:0 6px 16px rgba(0,0,0,.18)}
.candidate-row.cand-0 .cand-badge{background:linear-gradient(145deg,#60a5fa,#1d4ed8)}
.candidate-row.cand-1 .cand-badge{background:linear-gradient(145deg,#34d399,#047857)}
.candidate-row.cand-2 .cand-badge{background:linear-gradient(145deg,#22d3ee,#0e7490)}
.candidate-row.cand-3 .cand-badge{background:linear-gradient(145deg,#fbbf24,#b45309)}
.candidate-name{display:block;font-weight:600;font-size:.9rem;color:var(--text);line-height:1.3;letter-spacing:0;font-family:var(--font)}
.candidate-meta{display:block;font-size:.74rem;color:var(--muted);margin-top:3px;font-weight:600}
.cand-lead-tag{font-size:.6rem;font-weight:800;text-transform:uppercase;letter-spacing:.08em;padding:6px 11px;border-radius:999px;background:linear-gradient(135deg,#bbf7d0,#86efac);color:#065f46;flex-shrink:0;box-shadow:0 4px 12px rgba(16,185,129,.25)}
.progress{height:12px;background:linear-gradient(180deg,#e2e8f0,#f1f5f9);border-radius:999px;overflow:hidden;width:100%;box-shadow:inset 0 2px 4px rgba(15,23,42,.08)}
.progress-bar{height:100%;border-radius:999px;position:relative;overflow:hidden;min-width:0;transition:width .6s cubic-bezier(.34,1.2,.64,1);box-shadow:0 0 20px rgba(37,99,235,.35)}
.progress-bar:not(.empty)::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.45),transparent);animation:shimmer 2.5s ease-in-out infinite}
@keyframes shimmer{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}
.progress-bar.empty{width:0!important;box-shadow:none}
.bar-0{background:linear-gradient(90deg,#60a5fa,#2563eb)}.bar-1{background:linear-gradient(90deg,#34d399,#059669)}.bar-2{background:linear-gradient(90deg,#22d3ee,#0891b2)}.bar-3{background:linear-gradient(90deg,#fbbf24,#d97706)}
.dash-feed .table-wrap{max-height:none;flex:1;min-height:0;overflow-y:auto;background:#fff}
.dash-feed .card-body{display:flex;flex-direction:column;min-height:0;padding:0;background:linear-gradient(180deg,#fafbff,#fff)}
.dash-feed table{min-width:0;width:100%;table-layout:fixed}
.dash-feed th,.dash-feed td{padding:9px 12px;font-size:.76rem;word-break:break-word}
.dash-feed th{font-size:.67rem;text-transform:uppercase;letter-spacing:.06em;font-weight:700;color:#64748b}
.dash-feed th:nth-child(1),.dash-feed td:nth-child(1){width:28%}
.dash-feed th:nth-child(2),.dash-feed td:nth-child(2){width:22%}
.dash-feed thead th{position:sticky;top:0;z-index:1;background:#f8fafc;border-bottom:2px solid var(--line)}
.dash-feed tbody tr{transition:background .15s ease}
.dash-feed tbody tr:hover{background:#eff6ff}
.dash-feed tbody tr:nth-child(even){background:#f8fafc}
.dash-feed-footer{padding:10px 14px;font-size:.72rem;color:var(--muted);border-top:1px solid var(--line);flex-shrink:0;background:#fff;font-weight:500}
.row{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px;margin-bottom:16px;align-items:stretch}
.col-3{grid-column:span 3}.col-4{grid-column:span 4}.col-6{grid-column:span 6}.col-8{grid-column:span 8}.col-12{grid-column:span 12}
.row>[class*="col-"]{min-width:0}
.candidate-info{flex:1;min-width:0}
.candidate-head{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:2px}
.candidate-votes{font-weight:700;color:var(--primary);font-size:.78rem}
.welcome-card{background:linear-gradient(135deg,#fff,#f8fbff);border-radius:20px;padding:22px 24px;margin-bottom:20px;box-shadow:var(--shadow);border:1px solid rgba(37,99,235,.08)}
.btn{border:0;border-radius:999px;padding:11px 20px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:6px;font-family:var(--font);font-size:.875rem;transition:all .25s cubic-bezier(.4,0,.2,1);letter-spacing:0;position:relative;overflow:hidden}
.btn::after{content:"";position:absolute;inset:0;background:linear-gradient(180deg,rgba(255,255,255,.12),transparent);pointer-events:none}
.btn:hover{transform:translateY(-2px);filter:brightness(1.06)}
.btn:active{transform:translateY(0) scale(.98)}
.btn-primary{background:linear-gradient(135deg,#2563eb,#0891b2);color:#fff;box-shadow:0 10px 28px rgba(37,99,235,.35)}
.btn-success{background:linear-gradient(135deg,#10b981,#059669);color:#fff;box-shadow:0 10px 28px rgba(16,185,129,.3)}
.btn-danger{background:linear-gradient(135deg,#f87171,#dc2626);color:#fff;box-shadow:0 10px 28px rgba(239,68,68,.3)}
.btn-light{background:rgba(255,255,255,.95);color:var(--text);border:1px solid var(--line);box-shadow:0 4px 14px rgba(15,23,42,.06)}
.btn-sm{padding:8px 14px;font-size:.78rem}
.badge{display:inline-flex;padding:5px 11px;border-radius:999px;font-size:.7rem;font-weight:700;letter-spacing:.02em;box-shadow:0 2px 6px rgba(0,0,0,.06)}
.b-vote{background:linear-gradient(135deg,#d1fae5,#a7f3d0);color:#065f46}.b-already{background:linear-gradient(135deg,#fee2e2,#fecaca);color:#991b1b}.b-vote-pending{background:linear-gradient(135deg,#fef3c7,#fde68a);color:#92400e}.b-enrolled{background:linear-gradient(135deg,#ede9fe,#ddd6fe);color:#5b21b6}.b-error{background:linear-gradient(135deg,#fee2e2,#fecaca);color:#991b1b}.b-info{background:linear-gradient(135deg,#e0f2fe,#bae6fd);color:#075985}
input,select,textarea{width:100%;border:2px solid var(--line);border-radius:12px;padding:12px 14px;font-size:.92rem;outline:none;background:rgba(255,255,255,.95);font-family:var(--font);transition:border-color .2s ease,box-shadow .2s ease}
input:focus,select:focus,textarea:focus{border-color:#60a5fa;box-shadow:0 0 0 4px rgba(37,99,235,.12)}
.form-group label{display:block;font-size:.75rem;text-transform:none;letter-spacing:0;color:var(--muted);font-weight:600;margin-bottom:7px}
.fingerprint-scan{width:80px;height:80px;border:3px solid transparent;border-radius:50%;margin:0 auto 14px;position:relative;background:linear-gradient(#fff,#fff) padding-box,linear-gradient(135deg,#22d3ee,#2563eb) border-box;box-shadow:0 0 30px rgba(37,99,235,.35);animation:pulse 2s infinite}
.fingerprint-scan::before{content:"👆";position:absolute;inset:0;display:grid;place-items:center;font-size:1.6rem;opacity:.85}
.fingerprint-scan::after{content:"";position:absolute;width:55%;height:2px;background:linear-gradient(90deg,transparent,#2563eb,transparent);top:50%;left:22%;animation:scan 2.2s infinite linear}
@keyframes scan{0%{transform:translateY(-32px);opacity:0}50%{opacity:1}100%{transform:translateY(32px);opacity:0}}
@keyframes pulse{0%,100%{box-shadow:0 0 20px rgba(37,99,235,.25)}50%{box-shadow:0 0 35px rgba(34,211,238,.55)}}
th,td{padding:12px 16px;border-bottom:1px solid var(--line);text-align:left;font-size:.88rem}
th{background:#f8fafc;color:var(--muted);font-weight:700;border-top:none;text-transform:uppercase;font-size:.72rem;letter-spacing:.05em}
tbody tr{transition:background .15s ease}
tbody tr:hover{background:#eff6ff}
.reg-side{text-align:center;padding:22px 18px;background:linear-gradient(165deg,rgba(248,250,252,.95),rgba(239,246,255,.9));border:1px solid var(--line);border-radius:20px;box-shadow:inset 0 1px 0 rgba(255,255,255,.9),0 8px 24px rgba(37,99,235,.06)}
.reg-step{padding:9px 14px;border-radius:999px;background:rgba(255,255,255,.8);border:1px solid var(--line);font-size:.78rem;font-weight:700;color:var(--muted);transition:all .2s ease}
.reg-step.active{background:linear-gradient(135deg,#dbeafe,#e0f2fe);border-color:#93c5fd;color:#1d4ed8;box-shadow:0 4px 12px rgba(37,99,235,.15)}
.reg-step.done{background:linear-gradient(135deg,#d1fae5,#ecfdf5);border-color:#86efac;color:#047857}
.live-modal{position:fixed;inset:0;z-index:1000;display:none;align-items:center;justify-content:center;padding:22px;background:rgba(3,7,18,.65);backdrop-filter:blur(16px) saturate(1.2)}
.live-modal.show{display:flex;animation:fadeIn .25s ease}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.live-card{width:min(540px,96vw);background:rgba(255,255,255,.98);border-radius:28px;box-shadow:0 30px 90px rgba(0,0,0,.35);padding:32px 34px;text-align:center;border:none;animation:popIn .4s cubic-bezier(.34,1.4,.64,1);backdrop-filter:blur(20px);position:relative;overflow:hidden}
.live-card::before{content:"";position:absolute;top:0;left:0;right:0;height:5px;background:linear-gradient(90deg,#2563eb,#06b6d4,#8b5cf6)}
@keyframes popIn{from{opacity:0;transform:scale(.9) translateY(16px)}to{opacity:1;transform:scale(1) translateY(0)}}
.live-card h2{font-family:var(--font);font-weight:700;letter-spacing:0}
.banner{position:fixed;right:22px;top:22px;z-index:1001;width:min(420px,calc(100vw - 44px));display:none;background:rgba(255,255,255,.97);border-radius:18px;border:none;box-shadow:0 20px 50px rgba(15,23,42,.2);padding:18px 20px 18px 24px;backdrop-filter:blur(16px);animation:slideIn .4s cubic-bezier(.34,1.3,.64,1);overflow:hidden}
.banner::before{content:"";position:absolute;left:0;top:0;bottom:0;width:5px;background:linear-gradient(180deg,#2563eb,#06b6d4)}
.banner.success::before{background:linear-gradient(180deg,#10b981,#059669)}.banner.error::before{background:linear-gradient(180deg,#ef4444,#dc2626)}.banner.info::before{background:linear-gradient(180deg,#0ea5e9,#2563eb)}
.banner.show{display:block}.banner.success,.banner.error,.banner.info{border-left:none}
::-webkit-scrollbar{width:7px;height:7px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:linear-gradient(180deg,#cbd5e1,#94a3b8);border-radius:999px}
.stat-card .sub{font-size:.72rem;color:var(--muted);margin-top:4px}
.modal-icon{width:68px;height:68px;border-radius:50%;display:grid;place-items:center;font-size:1.75rem;margin:0 auto 18px;font-weight:800;line-height:1;box-shadow:0 10px 28px rgba(0,0,0,.1)}
.modal-icon.success{background:linear-gradient(135deg,#dcfce7,#bbf7d0);color:#15803d}
.modal-icon.error{background:linear-gradient(135deg,#fee2e2,#fecaca);color:#b91c1c}
.modal-icon.info{background:linear-gradient(135deg,#dbeafe,#bfdbfe);color:#1d4ed8}
.modal-icon.warning{background:linear-gradient(135deg,#fef3c7,#fde68a);color:#b45309}
.live-card.modal-success{border-top-color:var(--green)}.live-card.modal-error{border-top-color:var(--red)}.live-card.modal-info{border-top-color:var(--info)}.live-card.modal-warning{border-top-color:var(--amber)}
.live-card h2{margin:0 0 8px;font-size:1.25rem;color:var(--text)}
.live-card p{color:var(--muted);line-height:1.55;margin:0 0 16px;font-size:.92rem;font-weight:500}
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:4px;text-align:left}
.detail.hidden{display:none}
.detail span{display:block;color:var(--muted);font-size:.7rem;text-transform:uppercase;font-weight:700;margin-bottom:4px;letter-spacing:.05em}
.detail strong{display:block;font-size:.92rem;color:var(--text);word-break:break-word;font-weight:700}
.status-badge{display:inline-flex;padding:5px 10px;border-radius:999px;font-size:.76rem;font-weight:700;background:#f1f5f9;color:var(--muted)}
.status-badge.ok{background:linear-gradient(135deg,#d1fae5,#a7f3d0);color:#047857}.status-badge.bad{background:linear-gradient(135deg,#fee2e2,#fecaca);color:#991b1b}
.form-group{margin-bottom:14px}
textarea{min-height:72px;resize:vertical}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.form-grid .full{grid-column:span 2}
.table-wrap{overflow-x:auto;overflow-y:auto;max-height:420px;-webkit-overflow-scrolling:touch}
table{width:100%;border-collapse:collapse;min-width:640px;table-layout:auto}
.progress-table .progress{min-width:120px}
tr.leading-row td{background:#f0fdf4}
.welcome-card h2{margin:0 0 6px;font-size:1.35rem;color:var(--text);font-weight:700}
.welcome-card p{margin:0;color:var(--muted);line-height:1.5;max-width:900px}
.panel-card{transition:transform .25s ease,box-shadow .25s ease}
.sensor-note{background:linear-gradient(135deg,#eff6ff,#dbeafe);border:1px solid #bfdbfe;border-radius:14px;padding:12px 16px;margin-bottom:14px;font-size:.88rem;color:#1e3a8a}
.toolbar{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:16px}
.register-warning{display:none;background:linear-gradient(135deg,#fffbeb,#fef3c7);border:1px solid #fde68a;color:#92400e;padding:11px 14px;border-radius:12px;margin:12px 0 0;font-size:.88rem;font-weight:600}
.register-warning.show{display:block}
.register-steps{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
.vote-activity-item{padding:11px 14px;border-left:4px solid transparent;border-radius:12px;margin-bottom:8px;background:rgba(248,250,252,.9);transition:all .28s ease;font-size:.86rem;line-height:1.45;word-break:break-word;border:1px solid var(--line);border-left-width:4px}
.vote-activity-item:hover{background:#eff6ff;border-left-color:var(--primary);transform:translateX(3px);box-shadow:0 4px 16px rgba(37,99,235,.08)}
.actions-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
.action-tile{text-align:center;padding:22px 16px;cursor:pointer;border:1px solid var(--line);border-radius:18px;background:rgba(255,255,255,.92);transition:all .28s cubic-bezier(.4,0,.2,1);height:100%;backdrop-filter:blur(8px)}
.action-tile:hover{transform:translateY(-3px);box-shadow:0 14px 32px rgba(15,23,42,.1);border-color:rgba(37,99,235,.2)}
.action-icon{font-size:1.9rem;margin-bottom:10px;line-height:1;filter:drop-shadow(0 2px 8px rgba(0,0,0,.08))}
.action-tile h4{margin:0 0 6px;font-size:.95rem;font-family:var(--font);font-weight:600}
.action-tile p{margin:0;color:var(--muted);font-size:.82rem;line-height:1.4;min-height:2.8em}
.action-tile .btn-go{margin-top:12px;background:linear-gradient(135deg,var(--primary),var(--primary-light));color:#fff;border:0;border-radius:999px;padding:9px 16px;font-weight:700;cursor:pointer;font-size:.82rem;width:100%;max-width:180px;pointer-events:none;box-shadow:0 6px 18px rgba(37,99,235,.25)}
.action-tile.danger .btn-go{background:linear-gradient(135deg,#f87171,var(--red))}
.detail{border:1px solid var(--line);background:linear-gradient(180deg,#fff,#f8fafc);border-radius:14px;padding:11px 13px;transition:transform .15s ease}
.detail:hover{transform:translateY(-1px)}
@keyframes slideIn{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:translateX(0)}}
.results-toolbar-meta{font-size:.78rem;color:var(--muted);font-weight:600;background:rgba(255,255,255,.7);padding:6px 12px;border-radius:999px;border:1px solid var(--line)}
.page.active{animation:pageIn .35s ease}
@keyframes pageIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
.reg-stats .stat-card{padding:12px 10px;text-align:center;border-top:3px solid transparent;transition:transform .2s ease}
.reg-stats .stat-card:hover{transform:translateY(-3px)}
.reg-stats .stat-card .stat-icon{width:32px;height:32px;border-radius:9px;display:grid;place-items:center;font-size:1rem;margin:0 auto 8px;background:#f1f5f9}
.reg-stats .stat-card.stat-accent-1{border-top-color:#3b82f6}.reg-stats .stat-card.stat-accent-1 .stat-icon{background:#dbeafe;color:#1d4ed8}
.reg-stats .stat-card.stat-accent-2{border-top-color:#22c55e}.reg-stats .stat-card.stat-accent-2 .stat-icon{background:#dcfce7;color:#15803d}
.reg-stats .stat-card.stat-accent-3{border-top-color:#f59e0b}.reg-stats .stat-card.stat-accent-3 .stat-icon{background:#fef3c7;color:#b45309}
.reg-stats .stat-card.stat-accent-4{border-top-color:#8b5cf6}.reg-stats .stat-card.stat-accent-4 .stat-icon{background:#ede9fe;color:#6d28d9}
.reg-stats .stat-card .value{font-size:1.5rem}
.reg-stats .stat-card .label{font-size:.65rem}
.reg-side .register-steps{flex-direction:column;align-items:stretch;margin-bottom:0}
.reg-side .reg-step{text-align:center}
.reg-card .card-body{padding:20px 22px}
.reg-layout{display:grid;grid-template-columns:220px 1fr;gap:22px;align-items:start}
.reg-side .fingerprint-scan{margin:0 auto 14px}
.reg-hint{font-size:.82rem;color:var(--muted);margin:6px 0 14px;line-height:1.45}
.reg-form-wrap{min-width:0}
.reg-toolbar{margin-top:4px;padding-top:4px}
.reg-page .row{margin-bottom:12px}
.results-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:14px}
.results-toolbar .toolbar{margin-bottom:0}
.res-page .dash-split{margin-bottom:14px}
.res-results-card{margin-bottom:14px}
.res-results-card .card-body{background:linear-gradient(180deg,#fafbff,#f8fafc);padding:16px 18px}
.res-history-card{margin-top:0}
.res-history-card .card-body{padding:0}
.res-history-card .table-wrap{max-height:360px}
.res-history-card th,.res-history-card td{font-size:.82rem;padding:10px 12px}
.res-history-card tbody tr:nth-child(even){background:#f8fafc}
.res-page .progress-table .card-body{padding:14px 16px;background:#fafbfc}
.records-page .row{margin-bottom:12px}
.records-card .card-body{padding:0}
.records-card .table-wrap{max-height:480px}
.records-card th,.records-card td{font-size:.82rem;padding:10px 12px}
.records-card tbody tr:nth-child(even){background:#f8fafc}
.records-card tbody tr{transition:background .15s ease}
.records-card tbody tr:hover{background:#eff6ff}
.records-card thead th{position:sticky;top:0;z-index:1;background:#f8fafc}
.main.dash-view .live-pill{display:none}
@media(max-height:760px){.page-shell{padding:10px 8%}.main.dash-view{padding:12px 16px 14px}.main.dash-view .topbar h2{font-size:1.1rem}.dash-stats .stat-card{padding:10px 8px}.dash-stats .stat-card .value{font-size:1.3rem!important}.winner-banner{padding:8px 12px;font-size:.8rem;margin-bottom:8px}.dash-split .card-header{padding:9px 12px}.dash-split .card-body{padding:10px 12px}.candidate-row{padding:8px 10px}.progress{height:8px}.dash-feed th,.dash-feed td{padding:6px 8px;font-size:.7rem}}
@media(max-width:992px){:root{--page-margin:5%}.page-shell{padding:10px var(--page-margin);height:auto;min-height:100vh}.app{grid-template-columns:1fr;height:auto;min-height:calc(100vh - 20px);overflow:visible;border-radius:14px}.main{height:auto;overflow:visible}.page.dash-page.active{display:block;overflow:visible}.dash-split{grid-template-columns:1fr}.dash-votes .card-body{overflow-y:auto}.sidebar{position:sticky;top:0;z-index:50;padding:16px;height:auto;overflow:visible}.sidebar-nav{flex-direction:row;flex-wrap:wrap}.sidebar-nav button{width:auto;flex:1 1 calc(50% - 4px);justify-content:center;font-size:.82rem;padding:10px 8px}.device-box{margin-top:12px}.col-3,.col-4,.col-6,.col-8{grid-column:span 12}.actions-grid{grid-template-columns:1fr}}
@media(max-width:560px){:root{--page-margin:3%}.main{padding:14px 12px 24px}.topbar{flex-direction:column}.topbar-actions{width:100%}.topbar-actions .btn{flex:1}.form-grid{grid-template-columns:1fr}.form-grid .full{grid-column:span 1}}
</style>
</head>
<body>
<div class="page-shell">
<div class="app">
  <aside class="sidebar">
    <div class="brand">
      <div class="logo">VT</div>
      <div><h1>VoteTrack Pro</h1><p>Biometric election hub</p></div>
    </div>
    <div class="nav-label">Menu</div>
    <nav class="sidebar-nav">
      <button class="active" onclick="showPage('dashboard',this)">📊 Dashboard</button>
      <button onclick="showPage('register',this)">➕ Enroll Voter</button>
      <button onclick="showPage('history',this)">📈 Live Results</button>
      <button onclick="showPage('records',this)">👥 Voter Management</button>
    </nav>
    <div class="device-box">
      <b>ESP32 Dashboard</b><br>
      WiFi: <span id="wifiText">Checking...</span> <span id="wifiDot" class="dot"></span><br>
      IP: <span id="ipText">--</span><br>
      Last update: <span id="apiTime">--</span>
    </div>
  </aside>
 
  <main class="main dash-view">
    <header class="topbar">
      <div>
        <span class="live-pill"><span class="live-dot"></span> Live</span>
        <h2 id="pageTitle">Voting Dashboard</h2>
        <p id="pageSubtitle">Live fingerprint voting, candidate results, and voter records.</p>
      </div>
      <div class="topbar-actions">
        <button class="btn btn-light" id="soundToggleBtn" onclick="toggleSound()">🔇 Enable Sound</button>
        <button class="btn btn-success" onclick="exportVotesCsv()">Export Votes CSV</button>
        <button class="btn btn-danger" onclick="resetElection()">Reset Election</button>
      </div>
    </header>
  <section id="page-dashboard" class="page dash-page active">
    <div id="winnerAnnouncement" class="winner-banner empty"><span class="banner-icon">📊</span><span>No votes cast yet. Waiting for the first vote...</span></div>
 
    <div class="row dash-stats">
      <div class="col-3"><div class="card stat-card stat-accent-1"><div class="stat-icon">👥</div><div class="label">Total Voters</div><div class="value" id="dashTotalVoters">--</div></div></div>
      <div class="col-3"><div class="card stat-card stat-accent-2"><div class="stat-icon">🗳️</div><div class="label">Votes Cast</div><div class="value" id="dashVotesCast">0</div></div></div>
      <div class="col-3"><div class="card stat-card stat-accent-3"><div class="stat-icon">📈</div><div class="label">Turnout</div><div class="value" id="dashTurnout">0%</div></div></div>
      <div class="col-3"><div class="card stat-card stat-accent-4"><div class="stat-icon">🏆</div><div class="label">Leading</div><div class="value" id="dashLeading" style="font-size:1.05rem">--</div></div></div>
    </div>
 
    <div class="dash-split">
      <div class="card panel-card dash-votes">
        <div class="card-header"><span class="card-header-title">Vote Distribution</span><span class="card-header-meta" id="lastUpdated">--</span></div>
        <div class="card-body" id="voteDistributionPanel">Loading results...</div>
      </div>
      <div class="card panel-card dash-feed">
        <div class="card-header"><span class="card-header-title">Live Activity</span><button class="btn btn-light btn-sm" style="background:rgba(255,255,255,.2);color:#fff;border:0" onclick="refreshNow(true)">Refresh</button></div>
        <div class="card-body">
          <div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>Voter</th><th>Candidate</th></tr></thead><tbody id="liveBody"></tbody></table></div>
          <div id="debugStrip" class="dash-feed-footer">Waiting for dashboard data...</div>
        </div>
      </div>
    </div>
  </section>
 
  <section id="page-register" class="page reg-page">
    <div class="row reg-stats">
      <div class="col-3"><div class="card stat-card stat-accent-1"><div class="stat-icon">👥</div><div class="label">Total Enrolled</div><div class="value" id="totalEnrolled">--</div></div></div>
      <div class="col-3"><div class="card stat-card stat-accent-2"><div class="stat-icon">🗳️</div><div class="label">Votes Cast</div><div class="value" id="totalVotes">0</div></div></div>
      <div class="col-3"><div class="card stat-card stat-accent-3"><div class="stat-icon">🏆</div><div class="label">Leading</div><div class="value" id="leadingCandidate" style="font-size:1.05rem">--</div></div></div>
      <div class="col-3"><div class="card stat-card stat-accent-4"><div class="stat-icon">🔢</div><div class="label">Next ID</div><div class="value" id="nextId">--</div></div></div>
    </div>
    <div class="card reg-card">
      <div class="card-header"><span class="card-header-title">Voter Enrollment</span><span class="card-header-meta">Dashboard registration</span></div>
      <div class="card-body">
        <div class="reg-layout">
          <div class="reg-side">
            <div class="fingerprint-scan"></div>
            <strong id="registerStatusText">Ready</strong>
            <p class="reg-hint">Fill in voter details, then start registration and scan on the R503 sensor.</p>
            <div class="register-steps">
              <div class="reg-step active" id="regStepInfo">1. Voter Details</div>
              <div class="reg-step" id="regStepScan">2. Finger Scan</div>
              <div class="reg-step" id="regStepSave">3. Save Record</div>
            </div>
          </div>
          <div class="reg-form-wrap">
            <div class="form-grid">
              <div class="form-group"><label>Name</label><input id="enrollName" placeholder="Full name" autocomplete="name"></div>
              <div class="form-group"><label>ID No</label><input id="enrollIdNo" placeholder="Voter ID number"></div>
              <div class="form-group"><label>Date of Birth</label><input type="date" id="enrollDob"></div>
              <div class="form-group"><label>Sex</label><select id="enrollSex"><option value="">Select sex</option><option value="Male">Male</option><option value="Female">Female</option></select></div>
              <div class="form-group full"><label>City</label><input id="enrollCity" placeholder="City"></div>
            </div>
            <div id="registerWarning" class="register-warning">Please fill in Name, ID No, Date of Birth, Sex, and City before starting registration.</div>
            <div class="toolbar reg-toolbar">
              <button class="btn btn-success" onclick="startEnroll()">Start Registration</button>
              <button class="btn btn-light" onclick="clearRegisterForm()">Clear Form</button>
              <button class="btn btn-danger" onclick="cancelEnroll()">Cancel Enrollment</button>
            </div>
          </div>
        </div>
      </div>
    </div>
  </section>
 
  <section id="page-history" class="page res-page">
    <div id="resWinnerAnnouncement" class="winner-banner empty"><span class="banner-icon">📊</span><span>No votes cast yet. Waiting for the first vote...</span></div>
 
    <div class="row dash-stats">
      <div class="col-3"><div class="card stat-card stat-accent-1"><div class="stat-icon">👥</div><div class="label">Total Voters</div><div class="value" id="resTotalVoters">--</div></div></div>
      <div class="col-3"><div class="card stat-card stat-accent-2"><div class="stat-icon">🗳️</div><div class="label">Votes Cast</div><div class="value" id="resVotesCast">0</div></div></div>
      <div class="col-3"><div class="card stat-card stat-accent-3"><div class="stat-icon">📈</div><div class="label">Turnout</div><div class="value" id="resTurnout">0%</div></div></div>
      <div class="col-3"><div class="card stat-card stat-accent-4"><div class="stat-icon">🏆</div><div class="label">Leading</div><div class="value" id="resLeading" style="font-size:1.05rem">--</div></div></div>
    </div>
 
    <div class="card panel-card res-results-card">
      <div class="card-header"><span class="card-header-title">Live Results</span><span class="card-header-meta" id="resLastUpdated">Updated --</span></div>
      <div class="card-body" id="liveResultsPanel">Loading results...</div>
    </div>
 
    <div class="card panel-card res-history-card">
      <div class="card-header"><span class="card-header-title">Vote History</span><span class="card-header-meta">Recorded votes only</span></div>
      <div class="card-body">
        <div class="table-wrap"><table><thead><tr><th>Time</th><th>Voter</th><th>ID No</th><th>Finger ID</th><th>Candidate</th><th>Message</th></tr></thead><tbody id="historyBody"></tbody></table></div>
      </div>
    </div>
  </section>
 
  <section id="page-records" class="page records-page">
    <div class="row dash-stats">
      <div class="col-3"><div class="card stat-card stat-accent-1"><div class="stat-icon">👥</div><div class="label">Registered</div><div class="value" id="recRegistered">--</div></div></div>
      <div class="col-3"><div class="card stat-card stat-accent-2"><div class="stat-icon">🗳️</div><div class="label">Voted</div><div class="value" id="recVoted">0</div></div></div>
      <div class="col-3"><div class="card stat-card stat-accent-3"><div class="stat-icon">⏳</div><div class="label">Not Voted</div><div class="value" id="recPending">0</div></div></div>
      <div class="col-3"><div class="card stat-card stat-accent-4"><div class="stat-icon">🔢</div><div class="label">Next ID</div><div class="value" id="recNextId">--</div></div></div>
    </div>
 
    <div class="card panel-card records-card">
      <div class="card-header"><span class="card-header-title">Registered Voters</span><span class="card-header-meta"><span id="recLastUpdated">Updated --</span> · <span id="registeredCount">--</span> on file</span></div>
      <div class="card-body">
        <div class="table-wrap"><table><thead><tr><th>Finger ID</th><th>Name</th><th>ID No</th><th>DOB</th><th>Sex</th><th>City</th><th>Status</th><th>Actions</th></tr></thead><tbody id="recordsBody"></tbody></table></div>
      </div>
    </div>
  </section>
  </main>
</div>
</div>
 
<div id="banner" class="banner"><strong id="bannerTitle">Message</strong><div id="bannerText" style="font-size:.88rem;color:var(--muted);margin-top:4px">Details</div></div>
<div id="liveModal" class="live-modal"><div id="liveCard" class="live-card modal-info"><div id="modalIcon" class="modal-icon info">ℹ</div><h2 id="modalTitle">Notice</h2><p id="modalMessage">Action completed.</p><div class="detail-grid"><div class="detail" id="modalFieldName"><span>Name</span><strong id="modalName">-</strong></div><div class="detail" id="modalFieldId"><span>Finger ID</span><strong id="modalId">-</strong></div><div class="detail" id="modalFieldIdNo"><span>ID No</span><strong id="modalIdNo">-</strong></div><div class="detail" id="modalFieldDob"><span>Date of Birth</span><strong id="modalDob">-</strong></div><div class="detail" id="modalFieldSex"><span>Sex</span><strong id="modalSex">-</strong></div><div class="detail" id="modalFieldCity"><span>City</span><strong id="modalCity">-</strong></div><div class="detail" id="modalFieldCandidate"><span>Candidate</span><strong id="modalCandidate">-</strong></div><div class="detail" id="modalFieldEvent"><span>Status</span><strong id="modalEvent">-</strong></div></div><div style="margin-top:18px"><button class="btn btn-primary" onclick="closeModal()">Done</button></div></div></div>
 
<script>
let state={},logs=[],registeredFingerprints=[],lastSeenKey="",firstLoad=true,soundEnabled=false,modalTimer=null;
const CAND_COLORS=["bar-0","bar-1","bar-2","bar-3"];
function showPage(name,btn){
  document.querySelectorAll(".page").forEach(p=>p.classList.remove("active"));
  document.getElementById("page-"+name).classList.add("active");
  document.querySelectorAll(".sidebar-nav button").forEach(b=>b.classList.remove("active"));
  if(btn)btn.classList.add("active");
  const mainEl=document.querySelector(".main");
  if(mainEl)mainEl.classList.toggle("dash-view",name==="dashboard");
  if(name==="dashboard"){pageTitle.textContent="Voting Dashboard";pageSubtitle.textContent="Live fingerprint voting, candidate results, and voter records."}
  else if(name==="register"){pageTitle.textContent="Enroll Voter";pageSubtitle.textContent="Register voters with fingerprint scan on the R503 sensor."}
  else if(name==="history"){pageTitle.textContent="Live Results";pageSubtitle.textContent="Real-time vote distribution and election analytics."}
  else{pageTitle.textContent="Voter Management";pageSubtitle.textContent="View, refresh, and delete enrolled voters."}
}
async function api(path,options={}){const res=await fetch(path,options);if(!res.ok){const txt=await res.text();throw new Error(txt||("HTTP "+res.status))}return res.json()}
function toggleSound(){soundEnabled=!soundEnabled;const btn=document.getElementById("soundToggleBtn");if(soundEnabled){if(btn)btn.textContent="🔊 Disable Sound";beep("info")}else{if(btn)btn.textContent="🔇 Enable Sound"}}
function beep(type="info"){if(!soundEnabled)return;try{const C=window.AudioContext||window.webkitAudioContext;const ctx=new C();const osc=ctx.createOscillator();const gain=ctx.createGain();osc.connect(gain);gain.connect(ctx.destination);osc.frequency.value=type==="success"?880:type==="error"?180:520;gain.gain.setValueAtTime(.0001,ctx.currentTime);gain.gain.exponentialRampToValueAtTime(.15,ctx.currentTime+.02);gain.gain.exponentialRampToValueAtTime(.0001,ctx.currentTime+.18);osc.start();osc.stop(ctx.currentTime+.2)}catch(e){}}
function showBanner(type,title,text){bannerTitle.textContent=title;bannerText.textContent=text||"";banner.className="banner show "+(type==="success"?"success":type==="error"?"error":type==="info"?"info":"");setTimeout(()=>banner.className="banner",4500)}
function modalIconChar(type){if(type==="success")return"✓";if(type==="error")return"✕";if(type==="warning")return"!";return"ℹ"}
function normalizeModalData(data={}){return{name:data.name||"",id:data.id,collegeId:data.collegeId||data.idNo||"",note:data.note||data.dob||"",role:data.role||data.sex||"",department:data.department||data.city||"",candidate:data.candidate||"",event:data.event||""}}
function getEnrollFormData(){return{name:(enrollName?.value||"").trim(),collegeId:(enrollIdNo?.value||"").trim(),note:(enrollDob?.value||""),role:(enrollSex?.value||""),department:(enrollCity?.value||"").trim(),id:-1}}
function setModalFields(fields,data){const map={name:["modalFieldName","modalName"],id:["modalFieldId","modalId",v=>v>=0?String(v):"-"],idNo:["modalFieldIdNo","modalIdNo"],dob:["modalFieldDob","modalDob"],sex:["modalFieldSex","modalSex"],city:["modalFieldCity","modalCity"],candidate:["modalFieldCandidate","modalCandidate"],event:["modalFieldEvent","modalEvent"]};Object.keys(map).forEach(key=>{const[fieldId,valueId,fmt]=map[key];const wrap=document.getElementById(fieldId);const el=document.getElementById(valueId);const show=fields.includes(key);let val="";if(key==="name")val=data.name||"-";else if(key==="id")val=fmt?fmt(data.id):data.id;else if(key==="idNo")val=data.collegeId||"-";else if(key==="dob")val=data.note||"-";else if(key==="sex")val=data.role||"-";else if(key==="city")val=data.department||"-";else if(key==="candidate")val=data.candidate||"-";else if(key==="event")val=data.event||"-";if(wrap){wrap.classList.toggle("hidden",!show);if(show&&el)el.textContent=val||"-"}})}
function showModal(type,title,message,data={},options={}){const d=normalizeModalData(data);const fields=options.fields||["name","id","idNo","dob","sex","city"];liveCard.className="live-card modal-"+(type||"info");modalIcon.className="modal-icon "+(type||"info");modalIcon.textContent=modalIconChar(type||"info");modalTitle.textContent=title||"Notice";modalMessage.textContent=message||"";setModalFields(fields,d);liveModal.classList.add("show");if(modalTimer)clearTimeout(modalTimer);modalTimer=setTimeout(()=>closeModal(),options.autoCloseMs||8000)}
function closeModal(){liveModal.classList.remove("show");if(modalTimer){clearTimeout(modalTimer);modalTimer=null}}
function eventType(event){event=(event||"").toUpperCase();if(event==="VOTE")return"vote";if(event.includes("ALREADY VOTED"))return"already";if(event.includes("VOTE PENDING"))return"vote-pending";if(event.includes("VOTE TIMEOUT"))return"vote-timeout";if(event.includes("DUPLICATE"))return"duplicate";if(event.includes("NOT FOUND"))return"not-found";if(event.includes("ENROLLED"))return"enrolled";if(event.includes("ENROLL REQUEST"))return"enroll-request";if(event.includes("ENROLL"))return"enroll";if(event.includes("RESET"))return"reset";if(event.includes("CANCEL"))return"cancel";if(event.includes("ERROR"))return"error";return"info"}
function eventBadge(event){const t=eventType(event);if(t==="vote")return"b-vote";if(t==="already")return"b-already";if(t==="vote-pending")return"b-vote-pending";if(t==="duplicate")return"b-already";if(t==="vote-timeout")return"b-error";if(t==="enrolled")return"b-enrolled";if(t==="error"||t==="not-found")return"b-error";return"b-info"}
function eventKey(l){if(!l)return"";return[l.time,l.event,l.id,l.message,l.candidate].join("|")}
function personName(l){return l.name||(l.id>=0?("ID "+l.id):"System")}
function escapeHtml(v){return String(v??"").replace(/[&<>"']/g,ch=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[ch]))}
function getVoterTurnout(){const voters=Number(state.templates||0);const votes=Number(state.totalVotes||0);return voters>0?Math.round((votes/voters)*100):0}
function getLeadingInfo(){const counts=state.voteCounts||[0,0,0,0];const names=state.candidates||["Candidate 1","Candidate 2","Candidate 3","Candidate 4"];let max=-1,leader="--",leadVotes=0;counts.forEach((c,i)=>{const n=Number(c||0);if(n>max){max=n;leader=names[i]||("Candidate "+(i+1));leadVotes=n}});return{leader:max>0?leader:"--",leadVotes,max}}
function handleNewEvent(l){if(!l)return;const ev=(l.event||"").toUpperCase();const type=eventType(l.event);const name=personName(l);const msg=l.message||"";const cand=l.candidate||"";const data=normalizeModalData(l);if(type==="enrolled"){setRegisterStep(3);if(registerStatusText)registerStatusText.textContent="Saved successfully";showBanner("success","Registration Successful",name+" has been enrolled.");showModal("success","Registration Successful","Fingerprint saved and voter record created.",{...data,event:"Registered Successfully"},{fields:["name","id","idNo","dob","sex","city","event"],autoCloseMs:9000});beep("success")}else if(type==="vote"){showBanner("success","Voting Successful",name+" voted for "+(cand||"selected candidate")+".");showModal("success","Voting Successful","The vote was recorded successfully.",{...data,candidate:cand||data.candidate,event:"Vote Recorded"},{fields:["name","id","idNo","candidate","city"],autoCloseMs:9000});beep("success")}else if(type==="already"){showBanner("error","Already Voted",name+" has already voted in this election.");showModal("error","Already Voted","This voter cannot cast another vote.",{...data,event:"Already Voted"},{fields:["name","id","idNo","sex","city"],autoCloseMs:9000});beep("error")}else if(type==="vote-pending"){showBanner("info","Select Candidate",name+" verified — press a candidate button now.");showModal("info","Select Candidate Button","Press D5, D18, D19, or D23 within 15 seconds.",{...data,event:"Awaiting Button"},{fields:["name","id","idNo","city"],autoCloseMs:7000});beep("info")}else if(type==="vote-timeout"){showBanner("error","Vote Timed Out",name+" did not select a candidate in time.");showModal("error","Selection Timed Out","No candidate button was pressed in time. Scan again to vote.",{...data,event:"Timed Out"},{fields:["name","id","idNo"],autoCloseMs:9000});beep("error")}else if(type==="duplicate"){setRegisterStep(1);if(registerStatusText)registerStatusText.textContent="Duplicate detected";showBanner("error","Duplicate Registration","This fingerprint is already enrolled on the sensor.");showModal("error","Duplicate Registration","Scan a different finger or delete the existing record first.",{...data,event:"Duplicate Fingerprint"},{fields:["id","name","idNo","sex","city"],autoCloseMs:9000});beep("error")}else if(type==="not-found"){showBanner("error","Fingerprint Not Found",msg||"No matching voter was found.");showModal("error","Fingerprint Not Found",msg||"This fingerprint is not registered in the system.",{event:"Not Found",message:msg},{fields:["event"],autoCloseMs:8000});beep("error")}else if(type==="error"){const scanAgain=msg.toLowerCase().includes("not clear")||msg.toLowerCase().includes("could not read");if(registerStatusText)registerStatusText.textContent=scanAgain?"Scan again":"Needs attention";if(scanAgain)setRegisterStep(2);showBanner("error",scanAgain?"Scan Again":"Action Failed",msg||"Please try again.");showModal("error",scanAgain?"Scan Again":"Action Failed",scanAgain?"Place the same finger flat on the sensor and hold still.":(msg||"Please try again."),{...data,event:l.event||"Error"},{fields:scanAgain?["event"]:["name","idNo","event"],autoCloseMs:9000});beep("error")}else if(type==="enroll"){setRegisterStep(2);if(registerStatusText)registerStatusText.textContent="Scanning finger";const form=getEnrollFormData();showBanner("info","Scan Fingerprint","Place finger on the R503 sensor.");showModal("info","Scan Fingerprint","Complete fingerprint capture on the sensor.",{...form,...data,event:"Scan Required"},{fields:["name","idNo","dob","sex","city","event"],autoCloseMs:8000});beep("info")}else if(type==="enroll-request"){showBanner("info","Enrollment Queued","Waiting for fingerprint scan on the sensor.");beep("info")}else if(type==="reset"){showBanner("info","Election Reset","All votes have been cleared.");showModal("info","Election Reset","Vote counts reset. Every voter may vote again.",{event:"Election Reset"},{fields:["event"],autoCloseMs:7000});beep("info")}else if(type==="cancel"){setRegisterStep(1);if(registerStatusText)registerStatusText.textContent="Cancelled";showBanner("info","Enrollment Cancelled","Registration was cancelled.");showModal("warning","Enrollment Cancelled","You can fill the form and start again when ready.",{event:"Cancelled"},{fields:["event"],autoCloseMs:6000});beep("info")}}
function updateRecordsStats(){const people=buildPeopleRecords();const voted=people.filter(p=>p.status==="Voted").length;const registered=people.length;const pending=Math.max(registered-voted,0);if(recRegistered)recRegistered.textContent=registered;if(recVoted)recVoted.textContent=voted;if(recPending)recPending.textContent=pending;if(recNextId)recNextId.textContent=state.nextId??"--";if(registeredCount)registeredCount.textContent=registered}
function statusBadgeClass(status){if(status==="Voted")return"b-vote";if(status==="Registered")return"b-enrolled";return"b-info"}
function buildPeopleRecords(){const people={};registeredFingerprints.forEach(fp=>{const id=Number(fp.id);if(id<0)return;people[id]={id:id,name:fp.name||"",collegeId:fp.collegeId||"",role:fp.role||"",department:fp.department||"",dob:fp.dob||"",status:fp.name||fp.collegeId?"Registered":"Unassigned"}});logs.forEach(l=>{const id=Number(l.id);if(id<0||!people[id])return;if(l.name)people[id].name=l.name;if(l.collegeId)people[id].collegeId=l.collegeId;if(l.role)people[id].role=l.role;if(l.department)people[id].department=l.department;const ev=(l.event||"").toUpperCase();if(ev.includes("ENROLLED"))people[id].status="Registered";if(ev==="VOTE")people[id].status="Voted"});return Object.values(people).sort((a,b)=>a.id-b.id)}
function buildVoteLog(){return logs.filter(l=>(l.event||"").toUpperCase()==="VOTE").slice().reverse().map(l=>({time:l.time||"",name:l.name||("ID "+l.id),collegeId:l.collegeId||"-",id:l.id,candidate:l.candidate||"-",message:l.message||""}))}
function renderCandidatePanels(containerId,tableId){const el=document.getElementById(containerId);if(!el)return;const counts=state.voteCounts||[0,0,0,0];const names=state.candidates||["Candidate 1","Candidate 2","Candidate 3","Candidate 4"];const total=counts.reduce((a,b)=>a+Number(b||0),0);const lead=getLeadingInfo().leader;let html="";names.forEach((name,i)=>{const votes=Number(counts[i]||0);const pct=total>0?((votes/total)*100).toFixed(1):"0.0";const isLead=name===lead&&total>0;const barWidth=votes>0?Math.max(parseFloat(pct),8):0;html+=`<div class="candidate-row cand-${i%4} ${isLead?"leading":""}"><div class="candidate-main"><span class="cand-badge">${i+1}</span><div class="candidate-info"><span class="candidate-name">${escapeHtml(name)}</span><span class="candidate-meta">${votes} vote${votes===1?"":"s"} · ${pct}%</span></div>${isLead?'<span class="cand-lead-tag">Leading</span>':""}</div><div class="progress"><div class="progress-bar bar-${i%4} ${votes?"":"empty"}" style="width:${barWidth}%"></div></div></div>`});el.innerHTML=html||"<div style='color:var(--muted);text-align:center;padding:20px'>No votes yet.</div>";const tb=document.getElementById(tableId);if(tb){tb.innerHTML=names.map((name,i)=>{const votes=Number(counts[i]||0);const pct=total>0?((votes/total)*100).toFixed(1):"0.0";const isLead=name===lead&&total>0;const barWidth=votes>0?Math.max(parseFloat(pct),10):0;return `<tr class="${isLead?"leading-row":""}"><td>${escapeHtml(name)}</td><td>${votes}</td><td>${pct}%</td><td><div class="progress"><div class="progress-bar bar-${i%4} ${votes?"":"empty"}" style="width:${barWidth}%"></div></div></td><td>${isLead?"Leading":"-"}</td></tr>`}).join("")}}
function renderWinnerBanner(){const info=getLeadingInfo();const total=Number(state.totalVotes||0);const turnout=getVoterTurnout();const emptyHtml='<span class="banner-icon">📊</span><span>No votes cast yet. Waiting for the first vote...</span>';const leadHtml=`<span class="banner-icon">🏆</span><span><strong>${escapeHtml(info.leader)}</strong> is leading with ${info.leadVotes} votes (${total>0?Math.round((info.leadVotes/total)*100):0}% of total) · Turnout: ${turnout}%</span>`;["winnerAnnouncement","resWinnerAnnouncement"].forEach(id=>{const el=document.getElementById(id);if(!el)return;if(total===0){el.className="winner-banner empty";el.innerHTML=emptyHtml}else{el.className="winner-banner";el.innerHTML=leadHtml}})}
function renderLiveTicker(){const el=document.getElementById("liveTicker");if(!el)return;const items=logs.slice().reverse().slice(0,12);if(!items.length){el.innerHTML="<div class='vote-activity-item'>No activity yet.</div>";return}el.innerHTML=items.map(l=>`<div class="vote-activity-item"><strong>${escapeHtml(l.time||"")}</strong> — <span class="badge ${eventBadge(l.event)}">${escapeHtml(l.event||"INFO")}</span> ${escapeHtml(personName(l))}${l.candidate?" → "+escapeHtml(l.candidate):""}</div>`).join("")}
function renderSystemStatus(){const wifi=!!state.wifi;const wifiEl=document.getElementById("wifiStatus");const sensorEl=document.getElementById("sensorStatus");const deviceEl=document.getElementById("deviceStatus");const wifiTextEl=document.getElementById("wifiText");const wifiDotEl=document.getElementById("wifiDot");if(wifiTextEl)wifiTextEl.textContent=wifi?"Connected":"Offline";if(wifiDotEl)wifiDotEl.className="dot "+(wifi?"ok":"");if(wifiEl){wifiEl.textContent=wifi?"Connected":"Offline";wifiEl.className="status-badge "+(wifi?"ok":"bad")}if(sensorEl){sensorEl.textContent=state.sensor||"Ready";sensorEl.className="status-badge ok"}if(deviceEl){deviceEl.textContent=wifi?"Online":"Offline";deviceEl.className="status-badge "+(wifi?"ok":"bad")}}
function renderTables(){const onDash=document.getElementById("page-dashboard")?.classList.contains("active");const liveRows=logs.slice().reverse().map(l=>onDash?`<tr><td>${escapeHtml(l.time||"")}</td><td><span class="badge ${eventBadge(l.event)}">${escapeHtml(l.event||"INFO")}</span></td><td>${escapeHtml(l.name||"-")}</td><td>${escapeHtml(l.candidate||"-")}</td></tr>`:`<tr><td>${escapeHtml(l.time||"")}</td><td><span class="badge ${eventBadge(l.event)}">${escapeHtml(l.event||"INFO")}</span></td><td>${l.id>=0?l.id:"-"}</td><td>${escapeHtml(l.name||"-")}</td><td>${escapeHtml(l.candidate||"-")}</td><td>${escapeHtml(l.message||"")}</td></tr>`).join("");if(liveBody)liveBody.innerHTML=liveRows||`<tr><td colspan="${onDash?4:6}">No logs yet.</td></tr>`;const historyRows=buildVoteLog().map(r=>`<tr><td>${escapeHtml(r.time)}</td><td>${escapeHtml(r.name)}</td><td>${escapeHtml(r.collegeId)}</td><td>${r.id}</td><td>${escapeHtml(r.candidate)}</td><td>${escapeHtml(r.message)}</td></tr>`).join("");if(historyBody)historyBody.innerHTML=historyRows||`<tr><td colspan="6">No votes recorded yet.</td></tr>`;const peopleRows=buildPeopleRecords().map(p=>`<tr><td>${p.id}</td><td>${escapeHtml(p.name||"-")}</td><td>${escapeHtml(p.collegeId||"-")}</td><td>${escapeHtml(p.dob||"-")}</td><td>${escapeHtml(p.role||"-")}</td><td>${escapeHtml(p.department||"-")}</td><td><span class="badge ${statusBadgeClass(p.status)}">${escapeHtml(p.status)}</span></td><td><button class="btn btn-danger btn-sm" onclick="deleteFingerprint(${p.id})">Delete</button></td></tr>`).join("");if(recordsBody)recordsBody.innerHTML=peopleRows||`<tr><td colspan="8">No registered voters yet.</td></tr>`;updateRecordsStats();renderCandidatePanels("voteDistributionPanel");renderCandidatePanels("liveResultsPanel");renderWinnerBanner();renderLiveTicker();renderSystemStatus();const lu=document.getElementById("lastUpdated");if(lu)lu.textContent="Updated "+new Date().toLocaleTimeString();const ru=document.getElementById("resLastUpdated");if(ru)ru.textContent="Updated "+new Date().toLocaleTimeString();const rc=document.getElementById("recLastUpdated");if(rc)rc.textContent="Updated "+new Date().toLocaleTimeString()}
function csvEscape(v){v=String(v??"");return '"'+v.replace(/"/g,'""')+'"'}
function exportVotesCsv(){const rows=buildVoteLog();if(!rows.length){showBanner("error","No Vote Data","No votes to export yet.");return}let csv="time,voter,college_id,fingerprint_id,candidate,message
";rows.slice().reverse().forEach(r=>{csv+=[csvEscape(r.time),csvEscape(r.name),csvEscape(r.collegeId),csvEscape(r.id),csvEscape(r.candidate),csvEscape(r.message)].join(",")+"
"});const blob=new Blob([csv],{type:"text/csv;charset=utf-8;"});const url=URL.createObjectURL(blob);const a=document.createElement("a");a.href=url;a.download="election_results_"+new Date().toISOString().split("T")[0]+".csv";document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(url);showBanner("success","Export Complete","Election results exported.")}
async function resetElection(){if(!confirm("Reset the entire election? This clears all vote counts and allows every voter to vote again."))return;try{await api("/api/reset-votes",{method:"POST"});showBanner("success","Election Reset","All votes cleared.");beep("success");refreshNow()}catch(e){showBanner("error","Reset Failed",e.message);beep("error")}}
function updateStatusUi(){const wifi=!!state.wifi;const voters=state.templates??"--";const votes=state.totalVotes??0;const turnout=getVoterTurnout()+"%";const leader=getLeadingInfo().leader;if(ipText)ipText.textContent=state.ip||"--";if(apiTime)apiTime.textContent=new Date().toLocaleTimeString();if(wifiText)wifiText.textContent=wifi?"Connected":"Offline";if(wifiDot)wifiDot.className="dot "+(wifi?"ok":"");["dashTotalVoters","resTotalVoters","totalEnrolled"].forEach(id=>{const el=document.getElementById(id);if(el)el.textContent=voters});["dashVotesCast","resVotesCast","totalVotes"].forEach(id=>{const el=document.getElementById(id);if(el)el.textContent=votes});["dashTurnout","resTurnout"].forEach(id=>{const el=document.getElementById(id);if(el)el.textContent=turnout});["dashLeading","resLeading","leadingCandidate"].forEach(id=>{const el=document.getElementById(id);if(el)el.textContent=leader});if(nextId)nextId.textContent=state.nextId??"--";if(debugStrip)debugStrip.innerHTML="Logs: <b>"+logs.length+"</b> | Votes: <b>"+votes+"</b> | Turnout: <b>"+turnout+"</b> | Enrolling: <b>"+(state.isEnrolling?"YES":"NO")+"</b> | Selecting: <b>"+(state.isSelectingVote?"YES":"NO")+"</b>";if(state.isEnrolling){setRegisterStep(2);if(registerStatusText)registerStatusText.textContent="Scanning finger"}}
function setRegisterStep(step){const info=document.getElementById("regStepInfo");const scan=document.getElementById("regStepScan");const save=document.getElementById("regStepSave");if(!info||!scan||!save)return;[info,scan,save].forEach(el=>{el.classList.remove("active","done")});if(step===1)info.classList.add("active");else if(step===2){info.classList.add("done");scan.classList.add("active")}else if(step===3){info.classList.add("done");scan.classList.add("done");save.classList.add("active")}}
function clearRegisterForm(){if(enrollName)enrollName.value="";if(enrollIdNo)enrollIdNo.value="";if(enrollDob)enrollDob.value="";if(enrollSex)enrollSex.value="";if(enrollCity)enrollCity.value="";const warn=document.getElementById("registerWarning");if(warn)warn.classList.remove("show");setRegisterStep(1);if(registerStatusText)registerStatusText.textContent="Ready"}
function validateRegisterForm(){const name=(enrollName?.value||"").trim();const idNo=(enrollIdNo?.value||"").trim();const dob=(enrollDob?.value||"").trim();const sex=(enrollSex?.value||"").trim();const city=(enrollCity?.value||"").trim();const warn=document.getElementById("registerWarning");if(!name||!idNo||!dob||!sex||!city){if(warn)warn.classList.add("show");showBanner("error","Missing Details","Enter Name, ID No, Date of Birth, Sex, and City.");return false}if(warn)warn.classList.remove("show");return true}
async function refreshNow(forcePopup=false){try{const newState=await api("/api/status");const logData=await api("/api/logs");let regData={fingerprints:[]};try{regData=await api("/api/registered")}catch(e){regData={fingerprints:[]}}state=newState||{};logs=logData.logs||[];registeredFingerprints=regData.fingerprints||[];updateStatusUi();renderTables();const latest=state.last||logs[logs.length-1]||null;const key=eventKey(latest);if(firstLoad){firstLoad=false;lastSeenKey=key;if(latest&&forcePopup)handleNewEvent(latest);return}if((forcePopup&&latest)||(key&&key!==lastSeenKey)){lastSeenKey=key;handleNewEvent(latest)}}catch(e){if(debugStrip)debugStrip.innerHTML="Dashboard API error: <b>"+e.message+"</b>";showBanner("error","API Error",e.message)}}
async function startEnroll(){if(!validateRegisterForm())return;const form=getEnrollFormData();const payload=new URLSearchParams();payload.append("name",form.name);payload.append("collegeId",form.collegeId);payload.append("role",form.role);payload.append("department",form.department);payload.append("note",form.note);try{setRegisterStep(2);if(registerStatusText)registerStatusText.textContent="Scanning finger";showBanner("info","Enrollment Started","Place finger on the R503 sensor.");beep("info");await api("/api/enroll",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:payload});setTimeout(()=>refreshNow(),500)}catch(e){setRegisterStep(1);if(registerStatusText)registerStatusText.textContent="Failed";showBanner("error","Enrollment Failed",e.message);showModal("error","Enrollment Failed",e.message,{...form,event:"Failed"},{fields:["name","idNo","event"],autoCloseMs:9000});beep("error")}}
async function cancelEnroll(){try{await api("/api/cancel",{method:"POST"});showBanner("info","Cancelled","Enrollment cancelled.");refreshNow()}catch(e){showBanner("error","Cancel Failed",e.message)}}
async function deleteFingerprint(id){if(!id)return;if(!confirm("Delete fingerprint ID "+id+" and remove this voter record?"))return;try{await api("/api/fingerprint?id="+encodeURIComponent(id),{method:"DELETE"});showBanner("success","Deleted","Voter ID "+id+" removed.");beep("success");refreshNow()}catch(e){showBanner("error","Delete Failed",e.message);beep("error")}}
setRegisterStep(1);refreshNow();setInterval(refreshNow,1000);
</script>
</body>
</html>
)rawliteral";
 
#endif


Testing & Demo of ESP32 Fingerprint Voting Machine

After completing the hardware connection and uploading the code, the ESP32 fingerprint voting machine is ready for testing.

When the device is powered on, the OLED display first shows the Wi-Fi connection status.

Once the ESP32 connects to the local Wi-Fi network, it displays the IP address on the OLED screen. This IP address can be opened in a web browser to access the live voting dashboard.

After Wi-Fi connection, the system checks the R503 fingerprint sensor. During this step, the OLED shows a checking message while the ESP32 communicates with the fingerprint module.

If the sensor is detected successfully, the OLED displays “Sensor Ready”, which means the fingerprint module is properly connected and ready for scanning.

Finally, the system enters scan mode. In this mode, the OLED displays “Place finger” and waits for a registered voter to scan before accepting a candidate button vote.

Accessing the Dashboard

After the ESP32 connects to the Wi-Fi network, it displays its local IP address on the OLED screen. In this demo, the IP address shown is 192.168.2.112. By entering this IP address in a web browser on a phone, laptop, or computer connected to the same Wi-Fi network, we can access the VoteTrack Pro live voting dashboard.

The dashboard page shows live election stats, vote distribution, Wi-Fi connection, latest update time, and live activity feed. From the left-side menu, we can open Dashboard, Enroll Voter, Live Results, and Voter Management.

The Enroll Voter page is used to add voter details such as name, ID number, date of birth, sex, and city, and then start fingerprint enrollment.

The Live Results page shows current vote counts, leading candidate, and vote history, while the Voter Management page displays registered voters and their voting status.

The dashboard also includes options to enable sound alerts, export vote data as a CSV file, and reset the election when required.

Registering Voters

To register a new voter, open the ESP32 web dashboard and go to the Enroll Voter section. Here, enter the voter details such as name, ID number, date of birth, sex, and city, then click the Start Registration button.

After starting the registration, the dashboard shows an “Enrollment Started” popup and the OLED display asks the user to place a finger on the R503 fingerprint sensor.

The sensor first captures the fingerprint image, then asks the user to remove the finger and place the same finger again for the second scan.

The OLED display shows the enrollment progress step by step, such as placing the finger, removing the finger, scanning again, and saving the fingerprint.

Once both scans are matched successfully, the fingerprint template is saved in the R503 sensor memory and the user details are stored in the ESP32.

The OLED display shows a success message, and the dashboard also displays a “Registration Successful” popup with the voter name, fingerprint ID, ID number, and other saved details.

If the same registered finger is scanned again during the registration process, the system detects it as a duplicate fingerprint.

After this, the registered voter can scan their finger and cast one vote during the election.

Casting a Vote

After voter registration, the system can be used to conduct the election.

When a registered voter places their finger on the R503 fingerprint sensor, the OLED display shows that the system is searching and verifying the fingerprint.

If the fingerprint matches a saved voter record and that voter has not voted yet, the ESP32 opens a voting window and asks the voter to press one of the four candidate buttons.

When the voter selects a candidate, the dashboard displays a vote success popup with the voter name, fingerprint ID, ID number, and selected candidate. The vote is saved immediately and shown in the live results panel.

If the same voter scans again after already voting, the system blocks duplicate voting and displays an “Already Voted” message on the OLED and dashboard.

If an unknown or unregistered finger is scanned, the OLED display shows a try again or search failed message.

This means the fingerprint is not found in the saved voter database. The dashboard also keeps updating the live activity feed, vote history, turnout, and registered voter records.

In the Live Results section, vote counts, percentages, leading candidate, and vote history are shown with voter name, ID number, fingerprint ID, candidate, and message.

The Voter Management section shows all registered voters along with their assigned fingerprint IDs, voting status, and delete option.

Apart from voter enrollment and vote casting, the dashboard also includes useful election management features. In the Voter Management section, all registered voters are listed with fingerprint ID, name, ID number, date of birth, sex, city, and voting status. If any voter record needs to be removed, the administrator can click the Delete button, and the ESP32 deletes that fingerprint ID from the R503 sensor memory as well as removes the saved voter details from the system.

The dashboard also provides an Export Votes CSV option and a Reset Election button. Exported vote data can be opened in Excel or Google Sheets for result analysis and record keeping.


Video Tutorial & Guide

I Built an ESP32 Fingerprint Voting Machine with Live Web Dashboard
Watch this video on YouTube.

Share. Facebook Twitter Pinterest LinkedIn Tumblr Email Reddit Telegram WhatsApp
Previous ArticleESP32 Fingerprint Attendance System with Live Web Dashboard

Related Posts

ESP32 Fingerprint Attendance System with Live Web Dashboard

ESP32 Fingerprint Attendance System with Live Web Dashboard

Updated:July 6, 20261K
IoT Based PM & Air Quality Monitoring System using ESP32

IoT Based PM & Air Quality Monitoring System using ESP32

Updated:June 14, 20261K
DIY ESP32 MLX90640 IR Thermal Camera with Live Web Display

DIY ESP32 MLX90640 IR Thermal Camera with Live Web Display

Updated:May 10, 20262K
IoT Activity Tracker with ESP32 & Accelerometer Gyroscope

IoT Activity Tracker with ESP32 & Accelerometer/Gyroscope

Updated:May 2, 2026

ESP32 IoT Vehicle Motion Analyzer with MPU6050 & LIS3MDL

Updated:April 27, 20261K
High-Accuracy Pitch, Roll, Yaw with ESP32 & BNO08x IMU

High-Accuracy Pitch, Roll, Yaw with ESP32 & BNO08x IMU

Updated:April 27, 20262K
Add A Comment

CommentsCancel reply

Latest Posts
ESP32 Fingerprint Voting Machine with Live Web Dashboard

ESP32 Fingerprint Voting Machine with Live Web Dashboard

July 12, 2026
ESP32 Fingerprint Attendance System with Live Web Dashboard

ESP32 Fingerprint Attendance System with Live Web Dashboard

July 6, 2026
IoT Based PM & Air Quality Monitoring System using ESP32

IoT Based PM & Air Quality Monitoring System using ESP32

June 14, 2026
DIY ESP32 MLX90640 IR Thermal Camera with Live Web Display

DIY ESP32 MLX90640 IR Thermal Camera with Live Web Display

May 10, 2026
IoT Activity Tracker with ESP32 & Accelerometer Gyroscope

IoT Activity Tracker with ESP32 & Accelerometer/Gyroscope

May 2, 2026
A Guide to Sourcing Obsolete ICs for Vintage Projects

Beyond AliExpress: A Guide to Sourcing Obsolete ICs for Vintage Projects

April 21, 2026

ESP32 IoT Vehicle Motion Analyzer with MPU6050 & LIS3MDL

April 27, 2026
Building a Smart Sensor Node with a BLE Microcontroller

Building a Smart Sensor Node with a BLE Microcontroller

February 26, 2026
Top Posts & Pages
  • C1001 60 GHz mmWave Sensor with ESP32 for Sleep, Fall, Life Detection
    C1001 60 GHz mmWave Sensor with ESP32 for Sleep, Fall, Life Detection
  • 12V DC to 220V AC Inverter Circuit & PCB
    12V DC to 220V AC Inverter Circuit & PCB
  • IoT AC Energy Meter with PZEM-004T & ESP32 WebServer
    IoT AC Energy Meter with PZEM-004T & ESP32 WebServer
  • Designing of MPPT Solar Charge Controller using Arduino
    Designing of MPPT Solar Charge Controller using Arduino
  • ESP32 CAN Bus Tutorial | Interfacing MCP2515 CAN Module with ESP32
    ESP32 CAN Bus Tutorial | Interfacing MCP2515 CAN Module with ESP32
  • How to use INA219 DC Current Sensor Module with Arduino
    How to use INA219 DC Current Sensor Module with Arduino
  • How to use INA226 DC Current Sensor with Arduino
    How to use INA226 DC Current Sensor with Arduino
  • Buck Converter: Basics, Working, Design & Application
    Buck Converter: Basics, Working, Design & Application
Categories
  • Arduino Projects (197)
  • Articles (60)
    • Learn Electronics (19)
    • Product Review (15)
    • Tech Articles (28)
  • Electronics Circuits (46)
    • 555 Timer Projects (21)
    • Op-Amp Circuits (7)
    • Power Electronics (13)
  • IoT Projects (206)
    • ESP32 MicroPython (7)
    • ESP32 Projects (83)
    • ESP32-CAM Projects (15)
    • ESP8266 Projects (76)
    • LoRa/LoRaWAN Projects (22)
  • Microcontrollers (38)
    • AMB82-Mini IoT AI Camera (4)
    • BLE Projects (18)
    • STM32 Projects (19)
  • Raspberry Pi (93)
    • Raspberry Pi Pico Projects (57)
    • Raspberry Pi Pico W Projects (12)
    • Raspberry Pi Projects (24)
Follow Us
  • Facebook
  • Twitter
  • Pinterest
  • Instagram
  • YouTube
About Us

“‘How to Electronics’ is a vibrant community for electronics enthusiasts and professionals. We deliver latest insights in areas such as Embedded Systems, Power Electronics, AI, IoT, and Robotics. Our goal is to stimulate innovation and provide practical solutions for students, organizations, and industries. Join us to transform learning into a joyful journey of discovery and innovation.

Copyright © How To Electronics. All rights reserved.
  • About Us
  • Disclaimer
  • Privacy Policy
  • Contact Us
  • Advertise With Us

Type above and press Enter to search. Press Esc to cancel.

Ad Blocker Enabled!
Ad Blocker Enabled!
Looks like you're using an ad blocker. Please allow ads on our site. We rely on advertising to help fund our site.