""" Hikvision Attendance Manager - Flet GUI (Flet 0.85 compatible) """ import flet as ft import sqlite3, os, threading, csv from datetime import datetime, timedelta import sys sys.path.insert(0, os.path.dirname(__file__)) from hikvision import ( HikvisionDevice, get_attendance, get_employees, get_stats, get_sync_logs, load_config, save_config, init_db, DB_PATH ) # Colors BG = "#0a0e1a"; CARD = "#111827"; CARD2 = "#1a2237" AC = "#00d4ff"; AC2 = "#7c3aed"; GR = "#10b981" RD = "#ef4444"; OR = "#f59e0b"; TX = "#e2e8f0" DIM = "#94a3b8"; BD = "#1e293b" def bdr(w=1, c=BD): s = ft.BorderSide(w, c) return ft.Border(s, s, s, s) def main(page: ft.Page): page.title = "Hikvision Attendance Manager" page.bgcolor = BG page.padding = 0 page.window.width = 1200 page.window.height = 800 page.theme_mode = ft.ThemeMode.DARK init_db() sync_running = [False] def stat_card(icon, title, value, color=AC, sub=""): return ft.Container( content=ft.Column([ ft.Row([ft.Icon(icon, color=color, size=24), ft.Text(title, size=12, color=DIM, weight=ft.FontWeight.W_500)], spacing=8), ft.Text(str(value), size=32, color=color, weight=ft.FontWeight.W_700), ft.Text(sub, size=11, color=DIM) if sub else ft.Container(), ], spacing=4), bgcolor=CARD, border_radius=12, padding=20, border=bdr(), expand=True, ) def build_dashboard(): stats = get_stats() today = datetime.now().strftime('%Y-%m-%d') conn = sqlite3.connect(DB_PATH); c = conn.cursor() c.execute("SELECT strftime('%Y-%m', date) as m, COUNT(*) FROM attendance GROUP BY m ORDER BY m") monthly = c.fetchall() c.execute("SELECT employee_no, MIN(time), MAX(time), COUNT(*) FROM attendance WHERE date=? GROUP BY employee_no ORDER BY MIN(time)", (today,)) today_detail = c.fetchall(); conn.close() mx = max([m[1] for m in monthly]) if monthly else 1 bars = [] for ms, cnt in monthly[-12:]: h = max(4, (cnt / mx) * 150) bars.append(ft.Column([ ft.Text(str(cnt), size=9, color=DIM, text_align=ft.TextAlign.CENTER), ft.Container(width=30, height=h, border_radius=ft.BorderRadius(6, 6, 0, 0), gradient=ft.LinearGradient(begin=ft.Alignment(0, -1), end=ft.Alignment(0, 1), colors=[AC, AC2])), ft.Text(ms[5:], size=10, color=DIM), ], spacing=2, horizontal_alignment=ft.CrossAxisAlignment.CENTER)) trows = [] for eno, fin, fout, sc in today_detail: trows.append(ft.Container( content=ft.Row([ ft.CircleAvatar(content=ft.Text(str(eno), size=11, weight=ft.FontWeight.W_600), bgcolor=AC2, radius=16), ft.Column([ ft.Text(f"NV #{eno}", size=13, color=TX, weight=ft.FontWeight.W_500), ft.Text(f"Vào: {fin} | Ra: {fout}", size=11, color=DIM), ], spacing=2, expand=True), ft.Container(content=ft.Text(f"{sc} lần", size=11, color=AC), bgcolor=ft.Colors.with_opacity(0.1, AC), border_radius=8, padding=ft.Padding(8, 4, 8, 4)), ], spacing=12), padding=ft.Padding(12, 8, 12, 8), border=ft.Border(bottom=ft.BorderSide(1, BD)), )) return ft.Container(content=ft.Column([ ft.Container(content=ft.Column([ ft.Text("📊 Dashboard", size=24, color=TX, weight=ft.FontWeight.W_700), ft.Text(f"Cập nhật: {datetime.now().strftime('%d/%m/%Y %H:%M')}", size=12, color=DIM), ], spacing=4), padding=ft.Padding(0, 0, 0, 16)), ft.Row([ stat_card(ft.Icons.PEOPLE, "Nhân viên", stats['employee_count'], AC, f"Hôm nay: {stats['today_count']} lần quét"), stat_card(ft.Icons.FINGERPRINT, "Bản ghi", f"{stats['total_records']:,}", GR, f"{stats['total_days']} ngày"), stat_card(ft.Icons.DATE_RANGE, "Từ ngày", stats['date_from'], OR, f"→ {stats['date_to']}"), ], spacing=16), ft.Container(height=16), ft.Row([ ft.Container(content=ft.Column([ ft.Text("Bản ghi theo tháng", size=14, color=TX, weight=ft.FontWeight.W_600), ft.Container(height=8), ft.Row(bars, spacing=8, alignment=ft.MainAxisAlignment.CENTER, scroll=ft.ScrollMode.AUTO), ]), bgcolor=CARD, border_radius=12, padding=20, border=bdr(), expand=2), ft.Container(content=ft.Column([ ft.Row([ft.Text("Hôm nay", size=14, color=TX, weight=ft.FontWeight.W_600), ft.Container(content=ft.Text(today, size=11, color=AC), bgcolor=ft.Colors.with_opacity(0.1, AC), border_radius=8, padding=ft.Padding(8, 4, 8, 4))], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), ft.Container(height=4), ft.Column(trows if trows else [ft.Text("Chưa có dữ liệu", size=12, color=DIM, italic=True)], scroll=ft.ScrollMode.AUTO, expand=True), ]), bgcolor=CARD, border_radius=12, padding=20, border=bdr(), expand=1, height=280), ], spacing=16), ], scroll=ft.ScrollMode.AUTO), padding=24, expand=True) def build_attendance(): df = ft.TextField(label="Từ ngày", value=datetime.now().strftime('%Y-%m-%d'), width=150, text_size=13, border_color=BD, focused_border_color=AC, color=TX, label_style=ft.TextStyle(color=DIM, size=11)) dt = ft.TextField(label="Đến ngày", value=datetime.now().strftime('%Y-%m-%d'), width=150, text_size=13, border_color=BD, focused_border_color=AC, color=TX, label_style=ft.TextStyle(color=DIM, size=11)) ef = ft.TextField(label="Mã NV", width=100, text_size=13, border_color=BD, focused_border_color=AC, color=TX, label_style=ft.TextStyle(color=DIM, size=11)) tbl = ft.Column([], scroll=ft.ScrollMode.AUTO, expand=True) rtx = ft.Text("", size=12, color=DIM) def search(e): rows = get_attendance(date_from=df.value or None, date_to=dt.value or None, employee_no=ef.value or None) rtx.value = f"Tìm thấy {len(rows)} bản ghi" items = [ft.Container(content=ft.Row([ ft.Text("Ngày", size=11, color=AC, weight=ft.FontWeight.W_600, width=100), ft.Text("Giờ", size=11, color=AC, weight=ft.FontWeight.W_600, width=80), ft.Text("Mã NV", size=11, color=AC, weight=ft.FontWeight.W_600, width=50), ft.Text("Tên NV", size=11, color=AC, weight=ft.FontWeight.W_600, width=120), ft.Text("Trạng thái", size=11, color=AC, weight=ft.FontWeight.W_600, expand=True), ], spacing=12), padding=ft.Padding(12, 8, 12, 8), bgcolor=CARD2, border_radius=ft.BorderRadius(8, 8, 0, 0))] for i, r in enumerate(rows[:500]): bg = CARD if i % 2 == 0 else CARD2 items.append(ft.Container(content=ft.Row([ ft.Text(r['date'], size=12, color=TX, width=100), ft.Text(r['time'], size=12, color=AC, width=80, weight=ft.FontWeight.W_600), ft.Container(content=ft.Text(str(r['employee_no']), size=11, color=TX, text_align=ft.TextAlign.CENTER), width=50, bgcolor=AC2, border_radius=6, padding=ft.Padding(4, 2, 4, 2)), ft.Text(r['employee_name'] or '', size=12, color=TX, width=120), ft.Text(r['status'] or '', size=12, color=DIM, expand=True), ], spacing=12), padding=ft.Padding(12, 6, 12, 6), bgcolor=bg)) if len(rows) > 500: items.append(ft.Text(f"... hiển thị 500/{len(rows)}", size=11, color=OR, italic=True)) tbl.controls = items page.update() return ft.Container(content=ft.Column([ ft.Text("📋 Dữ liệu Chấm công", size=24, color=TX, weight=ft.FontWeight.W_700), ft.Container(height=12), ft.Container(content=ft.Row([df, dt, ef, ft.ElevatedButton("Tìm kiếm", icon=ft.Icons.SEARCH, on_click=search, bgcolor=AC, color=BG, style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8))), rtx], spacing=12), bgcolor=CARD, border_radius=12, padding=16, border=bdr()), ft.Container(height=12), ft.Container(content=tbl, bgcolor=CARD, border_radius=12, border=bdr(), expand=True, clip_behavior=ft.ClipBehavior.ANTI_ALIAS), ]), padding=24, expand=True) def build_sync(): sf = ft.TextField(label="Từ ngày", value=datetime.now().strftime('%Y-%m-%d'), width=160, text_size=13, border_color=BD, focused_border_color=AC, color=TX, label_style=ft.TextStyle(color=DIM, size=11)) st = ft.TextField(label="Đến ngày", value=datetime.now().strftime('%Y-%m-%d'), width=160, text_size=13, border_color=BD, focused_border_color=AC, color=TX, label_style=ft.TextStyle(color=DIM, size=11)) pb = ft.ProgressBar(visible=False, color=AC, bgcolor=BD) pt = ft.Text("", size=12, color=DIM) slog = ft.Column([], scroll=ft.ScrollMode.AUTO, spacing=4) sbtn = ft.ElevatedButton("🔄 Đồng bộ dữ liệu", bgcolor=GR, color=BG, style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8)), height=45) def alog(msg, c=DIM): ts = datetime.now().strftime('%H:%M:%S') slog.controls.insert(0, ft.Text(f"[{ts}] {msg}", size=11, color=c)) if len(slog.controls) > 100: slog.controls.pop() page.update() def do_sync(e): if sync_running[0]: return sync_running[0] = True; sbtn.disabled = True; pb.visible = True; page.update() def run(): try: alog(f"Bắt đầu: {sf.value} → {st.value}", AC) dev = HikvisionDevice() ok, msg = dev.test_connection() if not ok: alog(f"❌ {msg}", RD); return alog("✅ Kết nối OK", GR) def prog(d, c, t): pt.value = f"{d}: {c} (tổng: {t})"; alog(f" {d}: {c}"); page.update() total, new, msg = dev.sync_date_range(sf.value, st.value, prog) alog(f"✅ Xong! {total} bản ghi, {new} mới", GR) pt.value = f"Hoàn tất: {total} bản ghi, {new} mới" except Exception as ex: alog(f"❌ {ex}", RD) finally: sync_running[0] = False; sbtn.disabled = False; pb.visible = False; page.update() threading.Thread(target=run, daemon=True).start() sbtn.on_click = do_sync logs = get_sync_logs() hrows = [] for l in logs[:10]: sc = GR if l['status'] == 'ok' else RD hrows.append(ft.Container(content=ft.Row([ ft.Icon(ft.Icons.CHECK_CIRCLE if l['status'] == 'ok' else ft.Icons.ERROR, color=sc, size=16), ft.Text(l['sync_time'][:16], size=11, color=DIM, width=130), ft.Text(f"+{l['records_added']}", size=11, color=GR, width=50), ft.Text(l['message'] or '', size=11, color=DIM, expand=True), ], spacing=8), padding=ft.Padding(8, 4, 8, 4))) return ft.Container(content=ft.Column([ ft.Text("🔄 Đồng bộ dữ liệu", size=24, color=TX, weight=ft.FontWeight.W_700), ft.Container(height=12), ft.Container(content=ft.Row([ ft.Icon(ft.Icons.SHIELD, color=GR, size=20), ft.Column([ ft.Text("An toàn — Không gây Lock thiết bị", size=13, color=GR, weight=ft.FontWeight.W_600), ft.Text("Sync 1 ngày = 1-2 requests (giới hạn 20). Cache local.", size=11, color=DIM), ], spacing=2, expand=True), ], spacing=12), bgcolor=ft.Colors.with_opacity(0.1, GR), border_radius=12, padding=16, border=ft.Border(ft.BorderSide(1, ft.Colors.with_opacity(0.3, GR)), ft.BorderSide(1, ft.Colors.with_opacity(0.3, GR)), ft.BorderSide(1, ft.Colors.with_opacity(0.3, GR)), ft.BorderSide(1, ft.Colors.with_opacity(0.3, GR)))), ft.Container(height=12), ft.Container(content=ft.Column([ft.Row([sf, st, sbtn], spacing=16), pb, pt], spacing=8), bgcolor=CARD, border_radius=12, padding=20, border=bdr()), ft.Container(height=16), ft.Row([ ft.Container(content=ft.Column([ ft.Text("Log đồng bộ", size=14, color=TX, weight=ft.FontWeight.W_600), ft.Container(height=8), slog, ]), bgcolor=CARD, border_radius=12, padding=16, border=bdr(), expand=1, height=300), ft.Container(content=ft.Column([ ft.Text("Lịch sử sync", size=14, color=TX, weight=ft.FontWeight.W_600), ft.Container(height=8), ft.Column(hrows if hrows else [ft.Text("Chưa có", size=12, color=DIM, italic=True)], scroll=ft.ScrollMode.AUTO), ]), bgcolor=CARD, border_radius=12, padding=16, border=bdr(), expand=1, height=300), ], spacing=16), ], scroll=ft.ScrollMode.AUTO), padding=24, expand=True) def build_export(): ef = ft.TextField(label="Từ ngày", value=(datetime.now()-timedelta(days=30)).strftime('%Y-%m-%d'), width=160, text_size=13, border_color=BD, focused_border_color=AC, color=TX, label_style=ft.TextStyle(color=DIM, size=11)) et = ft.TextField(label="Đến ngày", value=datetime.now().strftime('%Y-%m-%d'), width=160, text_size=13, border_color=BD, focused_border_color=AC, color=TX, label_style=ft.TextStyle(color=DIM, size=11)) es = ft.Text("", size=13, color=DIM) def exp_csv(e): rows = get_attendance(date_from=ef.value or None, date_to=et.value or None) if not rows: es.value = "❌ Không có dữ liệu"; page.update(); return fp = os.path.join(os.path.expanduser('~'), 'Desktop', f"ChamCong_{ef.value}_to_{et.value}.csv") with open(fp, 'w', newline='', encoding='utf-8-sig') as f: w = csv.DictWriter(f, fieldnames=['date','time','employee_no','employee_name','status']); w.writeheader() for r in rows: w.writerow({k: r.get(k,'') for k in ['date','time','employee_no','employee_name','status']}) es.value = f"✅ {len(rows)} bản ghi → {fp}"; page.update() def exp_xlsx(e): try: from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment, Border, Side except: es.value = "❌ Cần cài openpyxl"; page.update(); return rows = get_attendance(date_from=ef.value or None, date_to=et.value or None) if not rows: es.value = "❌ Không có dữ liệu"; page.update(); return wb = Workbook(); ws = wb.active; ws.title = "Chấm công" hf = Font(name='Arial', bold=True, color='FFFFFF', size=11) hfill = PatternFill(start_color='0066CC', end_color='0066CC', fill_type='solid') bd = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin')) ws.merge_cells('A1:E1') ws['A1'] = f"BÁO CÁO CHẤM CÔNG ({ef.value} → {et.value})" ws['A1'].font = Font(name='Arial', bold=True, size=14, color='003366') ws['A1'].alignment = Alignment(horizontal='center') for col, h in enumerate(['Ngày','Giờ','Mã NV','Tên NV','Trạng thái'], 1): cell = ws.cell(row=3, column=col, value=h); cell.font = hf; cell.fill = hfill; cell.alignment = Alignment(horizontal='center'); cell.border = bd for i, r in enumerate(rows, 4): for col, k in enumerate(['date','time','employee_no','employee_name','status'], 1): cell = ws.cell(row=i, column=col, value=r.get(k,'')); cell.border = bd; cell.font = Font(name='Arial', size=10) ws.column_dimensions['A'].width = 14; ws.column_dimensions['B'].width = 10; ws.column_dimensions['C'].width = 8 ws.column_dimensions['D'].width = 18; ws.column_dimensions['E'].width = 14 fp = os.path.join(os.path.expanduser('~'), 'Desktop', f"ChamCong_{ef.value}_to_{et.value}.xlsx") wb.save(fp); es.value = f"✅ {len(rows)} bản ghi → {fp}"; page.update() return ft.Container(content=ft.Column([ ft.Text("📥 Xuất báo cáo", size=24, color=TX, weight=ft.FontWeight.W_700), ft.Container(height=16), ft.Container(content=ft.Column([ ft.Row([ef, et], spacing=16), ft.Container(height=12), ft.Row([ ft.ElevatedButton("📊 Xuất Excel", icon=ft.Icons.TABLE_CHART, on_click=exp_xlsx, bgcolor=GR, color=BG, style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8)), height=45), ft.ElevatedButton("📄 Xuất CSV", icon=ft.Icons.FILE_DOWNLOAD, on_click=exp_csv, bgcolor=AC, color=BG, style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8)), height=45), ], spacing=16), ft.Container(height=8), es, ]), bgcolor=CARD, border_radius=12, padding=24, border=bdr()), ft.Container(height=16), ft.Container(content=ft.Column([ ft.Text("💡 Mẹo", size=14, color=AC, weight=ft.FontWeight.W_600), ft.Text("• File lưu trên Desktop", size=12, color=DIM), ft.Text("• Excel (.xlsx) có format đẹp với header", size=12, color=DIM), ], spacing=4), bgcolor=CARD, border_radius=12, padding=20, border=bdr()), ]), padding=24, expand=True) def build_settings(): cfg = load_config() ip = ft.TextField(label="IP thiết bị", value=cfg.get('device_ip','192.168.1.15'), width=200, text_size=13, border_color=BD, focused_border_color=AC, color=TX, label_style=ft.TextStyle(color=DIM, size=11)) usr = ft.TextField(label="Username", value=cfg.get('username','admin'), width=200, text_size=13, border_color=BD, focused_border_color=AC, color=TX, label_style=ft.TextStyle(color=DIM, size=11)) pwd = ft.TextField(label="Password", value=cfg.get('password',''), width=200, text_size=13, border_color=BD, focused_border_color=AC, color=TX, label_style=ft.TextStyle(color=DIM, size=11), password=True, can_reveal_password=True) port = ft.TextField(label="Port", value=str(cfg.get('port',80)), width=100, text_size=13, border_color=BD, focused_border_color=AC, color=TX, label_style=ft.TextStyle(color=DIM, size=11)) tr = ft.Text("", size=13) def test(e): tr.value = "Đang kiểm tra..."; tr.color = OR; page.update() dev = HikvisionDevice({'device_ip': ip.value, 'username': usr.value, 'password': pwd.value, 'port': int(port.value or 80)}) ok, msg = dev.test_connection() tr.value = f"{'✅' if ok else '❌'} {msg}"; tr.color = GR if ok else RD; page.update() def save(e): save_config({'device_ip': ip.value, 'username': usr.value, 'password': pwd.value, 'port': int(port.value or 80), 'timezone': '+08:00'}) tr.value = "✅ Đã lưu"; tr.color = GR; page.update() return ft.Container(content=ft.Column([ ft.Text("⚙️ Cài đặt", size=24, color=TX, weight=ft.FontWeight.W_700), ft.Container(height=16), ft.Container(content=ft.Column([ ft.Text("Kết nối thiết bị", size=16, color=TX, weight=ft.FontWeight.W_600), ft.Container(height=12), ft.Row([ip, port], spacing=16), ft.Row([usr, pwd], spacing=16), ft.Container(height=12), ft.Row([ ft.ElevatedButton("Test kết nối", icon=ft.Icons.WIFI_FIND, on_click=test, bgcolor=AC, color=BG, style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8))), ft.ElevatedButton("Lưu cài đặt", icon=ft.Icons.SAVE, on_click=save, bgcolor=GR, color=BG, style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=8))), tr, ], spacing=16), ], spacing=8), bgcolor=CARD, border_radius=12, padding=24, border=bdr()), ft.Container(height=16), ft.Container(content=ft.Column([ ft.Text("API Server (cho Excel)", size=16, color=TX, weight=ft.FontWeight.W_600), ft.Container(height=8), ft.Text("Chạy start_server.bat → Excel Power Query kết nối http://localhost:5000", size=12, color=DIM), ft.Text("Excel đọc từ cache local → 0% rủi ro Lock", size=12, color=GR), ], spacing=4), bgcolor=CARD, border_radius=12, padding=20, border=bdr()), ]), padding=24, expand=True) # Navigation area = ft.Container(expand=True) builders = [build_dashboard, build_attendance, build_sync, build_export, build_settings] def switch(i): area.content = builders[i]() page.update() nav = ft.NavigationRail( selected_index=0, label_type=ft.NavigationRailLabelType.ALL, min_width=80, bgcolor=CARD, indicator_color=ft.Colors.with_opacity(0.15, AC), on_change=lambda e: switch(e.control.selected_index), destinations=[ ft.NavigationRailDestination(icon=ft.Icons.DASHBOARD, selected_icon=ft.Icons.DASHBOARD, label="Dashboard"), ft.NavigationRailDestination(icon=ft.Icons.TABLE_CHART, selected_icon=ft.Icons.TABLE_CHART, label="Chấm công"), ft.NavigationRailDestination(icon=ft.Icons.SYNC, selected_icon=ft.Icons.SYNC, label="Đồng bộ"), ft.NavigationRailDestination(icon=ft.Icons.FILE_DOWNLOAD, selected_icon=ft.Icons.FILE_DOWNLOAD, label="Xuất file"), ft.NavigationRailDestination(icon=ft.Icons.SETTINGS, selected_icon=ft.Icons.SETTINGS, label="Cài đặt"), ], ) page.add(ft.Row([nav, ft.VerticalDivider(width=1, color=BD), area], expand=True, spacing=0)) switch(0) if __name__ == '__main__': ft.run(main)