import datetime as dt import json import os import re import tempfile import uuid import warnings import zipfile from pathlib import Path from urllib.parse import quote_plus from zoneinfo import ZoneInfo import gradio as gr import pandas as pd import requests warnings.filterwarnings("ignore", message="cmap value too big/small:*") try: from fpdf import FPDF HAS_FPDF = True except Exception: FPDF = None HAS_FPDF = False try: import matplotlib.dates as mdates import matplotlib.pyplot as plt HAS_MATPLOTLIB = True except Exception: HAS_MATPLOTLIB = False mdates = None plt = None def default_data_file(): configured = os.environ.get("DATA_FILE") if configured: return Path(configured) persistent_dir = Path("/data") if persistent_dir.exists(): return persistent_dir / "neil_log_cases.json" return Path("cases.json") DATA_FILE = default_data_file() APP_TITLE = "Neil日誌" APP_SHORT_NAME = "Neil日誌" APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Asia/Taipei") LOCAL_TZ = ZoneInfo(APP_TIMEZONE) ALL_CATEGORIES = "全部案場" CASE_CATEGORIES = ["投標評估", "規劃設計", "施工管理", "會議追蹤", "文件審查", "現場勘查"] CASE_STAGES = ["前期評估", "投標準備", "設計規劃", "施工中", "驗收結案", "暫緩"] REMINDER_TYPES = ["會議", "投標期限", "文件送審", "現場勘查", "請款/估驗", "追蹤待辦", "其他"] REMINDER_MINUTES = [0, 10, 30, 60, 180, 1440, 2880, 10080] TASK_STATUSES = ["未開始", "進行中", "待確認", "已完成", "暫緩"] TASK_PRIORITIES = ["一般", "重要", "緊急"] RISK_LEVELS = ["低", "中", "高"] RISK_STATUSES = ["觀察中", "處理中", "已解除"] EXPENSE_TYPES = ["設計", "材料", "工班", "設備", "行政", "其他"] GUIDE_TEXT = { "workbench": "先選案場,再更新階段、預算與下一步。每次更新都會反映到首頁、報表與案場摘要。", "task": "待辦要寫清楚「誰、何時、完成什麼」。風險要寫清楚影響、處置方式與負責人。", "cases": "新增案場時先填名稱、地點、客戶、階段與類別。後續所有會議、文件、提醒都會掛回案場。", "meeting": "錄音或上傳音檔後產生逐字稿與摘要;沒有音檔時可貼逐字稿,一樣能整理成決議與待辦。", "files": "照片用來記錄現場狀態,文件用來做 OCR 與摘要。上傳後會自動歸到案場歷史。", "budget": "支出要填日期、類型、金額與備註;附件要選類型,方便日後查合約、報價、圖說。", "calendar": "提醒用來管理投標期限、會議、送審、請款與現場勘查,可匯出到手機和平板行事曆。", "history": "用案場或關鍵字回查照片、文件與 OCR 內容,適合找舊資料與追蹤依據。", "reports": "用於產出案場總結、詳細資料、甘特圖與 PDF/Excel 報表。", } PM_NOTE_TEMPLATE = "下一步:\n1. 本週完成:\n2. 需追蹤事項:\n3. 風險/卡點:\n4. 負責人與期限:" MEETING_FOCUS_TEMPLATE = "本次會議請確認:\n1. 已決議事項\n2. 未決事項\n3. 負責人與期限\n4. 風險與需要協調事項" PHOTO_NOTE_TEMPLATE = "拍攝位置:\n現場狀態:\n需改善事項:\n負責人/期限:" DOC_NOTE_TEMPLATE = "文件類型:\n日期/版次:\n金額/數量:\n需簽核或追蹤事項:" RISK_TEMPLATE = "可能影響:\n處置策略:\n需要協調:\n預計解除條件:" EXPENSE_NOTE_TEMPLATE = "用途:\n對應項目:\n付款狀態:\n憑證/附件:" HF_API_URL = "https://api-inference.huggingface.co/models" HF_TOKEN = os.environ.get("HUGGINGFACEHUB_API_TOKEN") or os.environ.get("HF_TOKEN") HEADERS = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {} TRANSCRIBE_MODEL = os.environ.get("TRANSCRIBE_MODEL", "openai/whisper-large-v3") IMAGE_CAPTION_MODEL = os.environ.get("IMAGE_CAPTION_MODEL", "nlpconnect/vit-gpt2-image-captioning") OCR_MODEL = os.environ.get("OCR_MODEL", "microsoft/trocr-base-printed") TEXT_MODEL = os.environ.get("TEXT_MODEL", "") LOGIN_USER = os.environ.get("APP_LOGIN_USER", "Neil67") LOGIN_PASSWORD = os.environ.get("APP_LOGIN_PASSWORD", "") APP_ACCESS_KEY = os.environ.get("APP_ACCESS_KEY", "") def now_local(): return dt.datetime.now(LOCAL_TZ).replace(tzinfo=None, microsecond=0) def today_local(): return now_local().date() def now_iso(): return now_local().isoformat() def parse_iso_local(value): parsed = dt.datetime.fromisoformat(str(value or "")) if parsed.tzinfo: parsed = parsed.astimezone(LOCAL_TZ).replace(tzinfo=None) return parsed def bundled_data_file(): return Path("cases.json") def ensure_data_file(): if DATA_FILE.parent != Path("."): DATA_FILE.parent.mkdir(parents=True, exist_ok=True) if not DATA_FILE.exists(): source = bundled_data_file() if DATA_FILE != source and source.exists(): content = source.read_text(encoding="utf-8").strip() DATA_FILE.write_text(content or "[]", encoding="utf-8") else: DATA_FILE.write_text("[]", encoding="utf-8") def load_cases(): ensure_data_file() try: data = json.loads(DATA_FILE.read_text(encoding="utf-8")) except json.JSONDecodeError: backup = DATA_FILE.with_suffix(f".broken-{now_local():%Y%m%d%H%M%S}.json") DATA_FILE.replace(backup) DATA_FILE.write_text("[]", encoding="utf-8") return [] return [normalize_case(case) for case in data if isinstance(case, dict)] def save_cases(cases): normalized = [normalize_case(case) for case in cases] DATA_FILE.write_text(json.dumps(normalized, ensure_ascii=False, indent=2), encoding="utf-8") def storage_mode(): path_text = str(DATA_FILE).replace("\\", "/") if path_text.startswith("/data/"): return "永久儲存" return "暫存儲存" def data_storage_status(): try: count = len(load_cases()) return f"目前資料位置:`{DATA_FILE}`|模式:**{storage_mode()}**|案場:**{count}** 件" except Exception as exc: return f"資料讀取異常:{exc}" def export_data_backup(): cases = load_cases() path = Path(tempfile.gettempdir()) / f"NeilLog_backup_{now_local():%Y%m%d_%H%M%S}.json" path.write_text(json.dumps(cases, ensure_ascii=False, indent=2), encoding="utf-8") return str(path) def restore_data_backup(file_path): if not file_path: return (*refresh_all("請先選擇要還原的 Neil日誌備份 JSON。"), data_storage_status()) path = Path(str(file_path)) try: data = json.loads(path.read_text(encoding="utf-8")) except Exception as exc: return (*refresh_all(f"備份檔讀取失敗:{exc}"), data_storage_status()) if not isinstance(data, list): return (*refresh_all("備份格式不正確,請上傳 Neil日誌匯出的 JSON 檔。"), data_storage_status()) save_cases([case for case in data if isinstance(case, dict)]) return (*refresh_all(f"已還原備份:{len(data)} 筆案場資料。"), data_storage_status()) def normalize_case(case): case = dict(case) case.setdefault("id", str(uuid.uuid4())) case.setdefault("site_name", "") case.setdefault("location", "") case.setdefault("client", "") case.setdefault("stage", CASE_STAGES[0]) case.setdefault("category", CASE_CATEGORIES[0]) case.setdefault("tags", []) case.setdefault("bid_amount", "") case.setdefault("manager_notes", "") case.setdefault("created_at", now_iso()) case.setdefault("updated_at", case.get("created_at", now_iso())) case.setdefault("meetings", []) case.setdefault("photo_history", []) case.setdefault("ocr_documents", []) case.setdefault("attachments", []) case.setdefault("reminders", []) case.setdefault("tasks", []) case.setdefault("risks", []) case.setdefault("expenses", []) if isinstance(case["tags"], str): case["tags"] = [tag.strip() for tag in case["tags"].split(",") if tag.strip()] return case def to_number(value): cleaned = "".join(ch for ch in str(value or "") if ch.isdigit() or ch in ".-") try: return float(cleaned) if cleaned else 0.0 except ValueError: return 0.0 def money_text(value): number = to_number(value) if not number: return "0" return f"{number:,.0f}" def normalize_date_text(value, field_name="日期", allow_empty=True): if isinstance(value, dt.datetime): if value.tzinfo: value = value.astimezone(LOCAL_TZ).replace(tzinfo=None) return value.date().isoformat() value = str(value or "").strip() if not value: if allow_empty: return "" raise ValueError(f"請輸入{field_name}。") try: parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) if parsed.tzinfo: parsed = parsed.astimezone(LOCAL_TZ).replace(tzinfo=None) return parsed.date().isoformat() except ValueError: pass for fmt in ("%Y-%m-%d", "%Y/%m/%d"): try: return dt.datetime.strptime(value, fmt).date().isoformat() except ValueError: continue raise ValueError(f"{field_name}格式請用 YYYY-MM-DD。") def normalize_task_datetime(value, field_name="時間", allow_empty=True): if isinstance(value, dt.datetime): if value.tzinfo: value = value.astimezone(LOCAL_TZ).replace(tzinfo=None) return value.strftime("%Y-%m-%d %H:%M") value = str(value or "").strip() if not value: if allow_empty: return "" raise ValueError(f"請輸入{field_name}。") text = value.replace(" ", " ").replace("/", "-") text = re.sub(r"\s+", " ", text).strip() iso_text = text.replace("Z", "+00:00") try: parsed = dt.datetime.fromisoformat(iso_text) if parsed.tzinfo: parsed = parsed.astimezone(LOCAL_TZ).replace(tzinfo=None) return parsed.strftime("%Y-%m-%d %H:%M") except ValueError: pass zh_match = re.search( r"(?P\d{4}-\d{1,2}-\d{1,2})\s*(?P上午|早上|中午|下午|晚上)?\s*(?P\d{1,2})(?:點|時|:)(?P\d{1,2}|半)?", text, ) if zh_match: date_part = zh_match.group("date") hour = int(zh_match.group("hour")) minute_text = zh_match.group("minute") minute = 30 if minute_text == "半" else int(minute_text or 0) period = zh_match.group("period") or "" if period in {"下午", "晚上"} and hour < 12: hour += 12 if period == "中午" and hour < 12: hour += 12 parsed_date = dt.datetime.strptime(date_part, "%Y-%m-%d") return parsed_date.replace(hour=hour, minute=minute).strftime("%Y-%m-%d %H:%M") text = text.replace("點半", ":30").replace("點", ":00").replace("時", ":00") formats = [ "%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d", ] for fmt in formats: try: parsed = dt.datetime.strptime(text, fmt) if fmt == "%Y-%m-%d": return parsed.date().isoformat() return parsed.strftime("%Y-%m-%d %H:%M") except ValueError: continue raise ValueError(f"{field_name}格式請用 2026-06-29 09:00,或 2026/06/29 上午9點。") def task_datetime_to_iso(value): value = str(value or "").strip() if not value: return "" for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d"): try: parsed = dt.datetime.strptime(value, fmt) if fmt == "%Y-%m-%d": parsed = parsed.replace(hour=9, minute=0) return parsed.isoformat() except ValueError: continue return "" def parse_minutes(value, default=0): if value in (None, ""): return default if isinstance(value, (int, float)): return int(value) match = re.search(r"\d+", str(value)) return int(match.group(0)) if match else default def current_categories(): categories = list(CASE_CATEGORIES) for case in load_cases(): category = case.get("category") if category and category not in categories: categories.append(category) return categories def parse_tags(tags): if isinstance(tags, list): return [str(tag).strip() for tag in tags if str(tag).strip()] return [tag.strip() for tag in str(tags or "").replace(",", ",").split(",") if tag.strip()] def site_label(case): name = case.get("site_name") or "未命名案場" location = case.get("location") or "未填地點" if not location or location == name: return name return f"{name}({location})" def google_maps_url(location): location = str(location or "").strip() if not location: return "" return f"https://www.google.com/maps/search/?api=1&query={quote_plus(location)}" def google_maps_markdown(location): url = google_maps_url(location) if not url: return "尚未輸入地點。" return f"[開啟 Google Maps]({url})" def location_map_preview(location): url = google_maps_url(location) if not url: return "輸入地點後會自動產生 Google Maps 連結。" return f"Google Maps:[{str(location).strip()}]({url})" def list_site_options(): return [site_label(case) for case in load_cases()] def get_case_by_label(label): if not label: return None label = str(label).strip() case_id = label.split("|")[-1].strip() for case in load_cases(): if case.get("id") == case_id or site_label(case) == label: return case return None def case_rows(category=ALL_CATEGORIES): rows = [] for case in load_cases(): if category and category != ALL_CATEGORIES and case.get("category") != category: continue rows.append( { "案場": case.get("site_name", ""), "地點": case.get("location", ""), "Google Maps": google_maps_url(case.get("location", "")), "類別": case.get("category", ""), "客戶": case.get("client", ""), "階段": case.get("stage", ""), "金額/預算": case.get("bid_amount", ""), "標籤": ", ".join(case.get("tags", [])), "會議": len(case.get("meetings", [])), "照片": len(case.get("photo_history", [])), "文件": len(case.get("ocr_documents", [])) + len(case.get("attachments", [])), "提醒": len(case.get("reminders", [])), "未完成待辦": sum(1 for item in case.get("tasks", []) if item.get("status") != "已完成"), "高風險": sum(1 for item in case.get("risks", []) if item.get("level") == "高" and item.get("status") != "已解除"), "更新時間": case.get("updated_at", ""), } ) return pd.DataFrame(rows) def category_overview(category=ALL_CATEGORIES): cases = load_cases() if category and category != ALL_CATEGORIES: cases = [case for case in cases if case.get("category") == category] if not cases: return "### 案場總覽\n目前沒有符合條件的案場。" grouped = {} for case in cases: grouped.setdefault(case.get("category") or "未分類", []).append(case) lines = ["### 案場總覽"] for group, items in sorted(grouped.items()): lines.append(f"#### {group}({len(items)} 件)") for case in items: lines.append( f"- **{case.get('site_name', '')}**|{case.get('location', '')}|{google_maps_markdown(case.get('location', ''))}|" f"{case.get('stage', '')}|{case.get('client', '') or '未填客戶'}" ) return "\n".join(lines) def add_case(site_name, location, client, stage, category, new_category, tags, bid_amount, manager_notes): if not str(site_name or "").strip(): return refresh_all("請先輸入案場名稱。") chosen_category = str(new_category or "").strip() or category or CASE_CATEGORIES[0] cases = load_cases() new_case = create_case_record(site_name, location, client, stage, chosen_category, tags, bid_amount, manager_notes) cases.append(new_case) save_cases(cases) return refresh_all_select_case(site_label(new_case), f"已新增並選取案場:{site_name}") def create_case_record(site_name, location="", client="", stage=None, category=None, tags="", bid_amount="", manager_notes=""): now = now_iso() return { "id": str(uuid.uuid4()), "site_name": str(site_name).strip(), "location": str(location or "").strip(), "client": str(client or "").strip(), "stage": stage or CASE_STAGES[0], "category": category or CASE_CATEGORIES[0], "tags": parse_tags(tags), "bid_amount": str(bid_amount or "").strip(), "manager_notes": str(manager_notes or "").strip(), "created_at": now, "updated_at": now, "meetings": [], "photo_history": [], "ocr_documents": [], "attachments": [], "reminders": [], "tasks": [], "risks": [], "expenses": [], } def refresh_all_select_case(selected_label, message="已更新。"): sites = list_site_options() categories = current_categories() return ( case_rows(), gr.update(choices=sites, value=selected_label), gr.update(choices=sites, value=selected_label), gr.update(choices=sites, value=selected_label), gr.update(choices=sites, value=selected_label), gr.update(choices=sites, value=selected_label), gr.update(choices=sites, value=selected_label), gr.update(choices=sites, value=selected_label), gr.update(choices=sites, value=selected_label), gr.update(choices=sites, value=selected_label), gr.update(choices=sites, value=selected_label), gr.update(choices=sites, value=selected_label), gr.update(choices=sites, value=selected_label), gr.update(choices=sites, value=selected_label), gr.update(choices=sites, value=selected_label), gr.update(choices=sites, value=selected_label), gr.update(choices=sites, value=selected_label), gr.update(choices=categories), gr.update(choices=[ALL_CATEGORIES] + categories, value=ALL_CATEGORIES), category_overview(), message, ) def quick_add_case_for_task(site_name, location): site_name = str(site_name or "").strip() location = str(location or "").strip() if not site_name: return refresh_all("請先輸入案場名稱,建立後就能直接新增待辦。") for case in load_cases(): same_name = case.get("site_name") == site_name same_location = not location or case.get("location", "") == location if same_name and same_location: label = site_label(case) return refresh_all_select_case(label, f"已選取既有案場:{label}") cases = load_cases() new_case = create_case_record(site_name, location) cases.append(new_case) save_cases(cases) label = site_label(new_case) return refresh_all_select_case(label, f"已新增並選取案場:{label}") def refresh_all(message="已更新。"): sites = list_site_options() categories = current_categories() return ( case_rows(), gr.update(choices=sites), gr.update(choices=sites), gr.update(choices=sites), gr.update(choices=sites), gr.update(choices=sites), gr.update(choices=sites), gr.update(choices=sites), gr.update(choices=sites), gr.update(choices=sites), gr.update(choices=sites), gr.update(choices=sites), gr.update(choices=sites), gr.update(choices=sites), gr.update(choices=sites), gr.update(choices=sites), gr.update(choices=sites), gr.update(choices=categories), gr.update(choices=[ALL_CATEGORIES] + categories, value=ALL_CATEGORIES), category_overview(), message, ) def filter_cases(category): return case_rows(category), category_overview(category) def template_text(kind): templates = { "pm_note": PM_NOTE_TEMPLATE, "meeting": MEETING_FOCUS_TEMPLATE, "photo": PHOTO_NOTE_TEMPLATE, "document": DOC_NOTE_TEMPLATE, "risk": RISK_TEMPLATE, "expense": EXPENSE_NOTE_TEMPLATE, } return templates.get(kind, "") def case_workbench(site): case = get_case_by_label(site) if not case: return "請先選擇案場。" checks = [] if not case.get("client"): checks.append("補上客戶/窗口") if not case.get("bid_amount"): checks.append("補上預算或合約金額") if not case.get("reminders"): checks.append("建立下一個提醒") if not case.get("tasks"): checks.append("建立至少一項待辦") if not case.get("manager_notes"): checks.append("補上 PM 下一步") upcoming = [] now = now_local() for item in case.get("reminders", []): try: start = parse_iso_local(item.get("start_at", "")) except ValueError: continue if start >= now: upcoming.append((start, item)) upcoming.sort(key=lambda pair: pair[0]) lines = [ f"### {case.get('site_name', '未命名案場')}", f"- 地點:{case.get('location', '') or '未填'}", f"- 地圖:{google_maps_markdown(case.get('location', ''))}", f"- 階段:{case.get('stage', '')}", f"- 類別:{case.get('category', '')}", f"- 客戶:{case.get('client', '') or '未填'}", f"- 預算/金額:{case.get('bid_amount', '') or '未填'}", f"- 會議:{len(case.get('meetings', []))} 筆", f"- 文件:{len(case.get('ocr_documents', [])) + len(case.get('attachments', []))} 筆", f"- 照片:{len(case.get('photo_history', []))} 筆", f"- 提醒:{len(case.get('reminders', []))} 筆", f"- 未完成待辦:{sum(1 for item in case.get('tasks', []) if item.get('status') != '已完成')} 項", f"- 未解除風險:{sum(1 for item in case.get('risks', []) if item.get('status') != '已解除')} 項", f"- 已登錄支出:{money_text(sum(to_number(item.get('amount')) for item in case.get('expenses', [])))}", "", "#### 專家檢核", "- " + "、".join(checks) if checks else "- 基本資料完整,請持續追蹤期限、風險與成本。", "", "#### 下一步提醒", ] if upcoming: for start, item in upcoming[:5]: lines.append(f"- {start:%Y-%m-%d %H:%M}|{item.get('type', '')}|{item.get('title', '')}") else: lines.append("- 尚無未來提醒,建議建立下一個追蹤節點。") lines.append("\n#### 優先待辦") open_tasks = [item for item in case.get("tasks", []) if item.get("status") != "已完成"] if open_tasks: for item in open_tasks[:5]: agreed = item.get("agreed_at") or "未填約定" due = item.get("due_at") or "未填期限" lines.append(f"- {item.get('priority', '')}|約定:{agreed}|期限:{due}|{item.get('owner', '') or '未填負責人'}|{item.get('title', '')}") else: lines.append("- 尚無未完成待辦。") lines.append("\n#### 風險控管") open_risks = [item for item in case.get("risks", []) if item.get("status") != "已解除"] if open_risks: for item in open_risks[:5]: lines.append(f"- {item.get('level', '')}|{item.get('status', '')}|{item.get('title', '')}") else: lines.append("- 尚無未解除風險。") lines.extend(["", "#### PM 備註", case.get("manager_notes", "") or "尚無備註。"]) return "\n".join(lines) def load_case_for_workbench(site): case = get_case_by_label(site) if not case: return "", "", "", "", "請先選擇案場。" return ( case.get("stage", CASE_STAGES[0]), case.get("category", CASE_CATEGORIES[0]), case.get("bid_amount", ""), case.get("manager_notes", ""), case_workbench(site), ) def update_case_from_workbench(site, stage, category, bid_amount, manager_notes): case = get_case_by_label(site) if not case: return case_rows(), build_hero_html(), "請先選擇案場。", "請先選擇案場。" cases = load_cases() for stored in cases: if stored["id"] == case["id"]: stored["stage"] = stage or stored.get("stage", "") stored["category"] = category or stored.get("category", "") stored["bid_amount"] = str(bid_amount or "").strip() stored["manager_notes"] = str(manager_notes or "").strip() stored["updated_at"] = now_iso() break save_cases(cases) return case_rows(), build_hero_html(), case_workbench(site), "案場狀態已更新。" def task_rows(site=None, only_open=False): rows = [] selected_case_id = None if site: case = get_case_by_label(site) selected_case_id = case.get("id") if case else None for case in load_cases(): if selected_case_id and case.get("id") != selected_case_id: continue for item in case.get("tasks", []): if only_open and item.get("status") == "已完成": continue rows.append( { "案場": case.get("site_name", ""), "狀態": item.get("status", ""), "優先": item.get("priority", ""), "約定時間": item.get("agreed_at", ""), "完成期限": item.get("due_at", ""), "負責人": item.get("owner", ""), "待辦": item.get("title", ""), "備註": item.get("notes", ""), "更新時間": item.get("updated_at", item.get("created_at", "")), } ) rows.sort(key=lambda row: (row["狀態"] == "已完成", row["完成期限"] or "9999-12-31", row["約定時間"] or "9999-12-31", row["優先"])) return pd.DataFrame(rows) def task_options(site=None, only_open=True): options = [] selected_case_id = None if site: case = get_case_by_label(site) selected_case_id = case.get("id") if case else None for case in load_cases(): if selected_case_id and case.get("id") != selected_case_id: continue for item in case.get("tasks", []): if only_open and item.get("status") == "已完成": continue label = f"{case.get('site_name', '')}|{item.get('priority', '')}|{item.get('due_at') or item.get('agreed_at') or '未填時間'}|{item.get('title', '')}" options.append((label, item.get("id", ""))) return options def add_task(site, title, owner, agreed_at, due_at, priority, status, sync_calendar, notes): case = get_case_by_label(site) if not case: return task_rows(), build_hero_html(), case_rows(), upcoming_reminders(30), gr.update(choices=[]), "請先選擇案場;若還沒有案場,請先在上方快速新增案場。" if not str(title or "").strip(): return task_rows(site), build_hero_html(), case_rows(), upcoming_reminders(30), gr.update(choices=task_options(site), value=None), "請輸入待辦事項。" try: agreed_at = normalize_task_datetime(agreed_at, "約定時間", allow_empty=True) due_at = normalize_task_datetime(due_at, "完成期限", allow_empty=True) except ValueError as exc: return task_rows(site), build_hero_html(), case_rows(), upcoming_reminders(30), gr.update(choices=task_options(site), value=None), str(exc) cases = load_cases() selected_label = site reminder_count = 0 for stored in cases: if stored["id"] == case["id"]: stored.setdefault("tasks", []).append( { "id": str(uuid.uuid4()), "title": str(title).strip(), "owner": str(owner or "").strip(), "agreed_at": str(agreed_at or "").strip(), "due_at": str(due_at or "").strip(), "priority": priority or TASK_PRIORITIES[0], "status": status or TASK_STATUSES[0], "notes": str(notes or "").strip(), "created_at": now_iso(), "updated_at": now_iso(), } ) if sync_calendar: reminders = stored.setdefault("reminders", []) for label, time_value in (("約定時間", agreed_at), ("完成期限", due_at)): start_iso = task_datetime_to_iso(time_value) if not start_iso: continue start_dt = dt.datetime.fromisoformat(start_iso) reminders.append( { "id": str(uuid.uuid4()), "title": f"{label}:{str(title).strip()}", "type": "追蹤待辦", "start_at": start_iso, "end_at": (start_dt + dt.timedelta(minutes=30)).isoformat(), "remind_before_minutes": 30, "owner": str(owner or "").strip(), "notes": str(notes or "").strip(), "created_at": now_iso(), } ) reminder_count += 1 stored["updated_at"] = now_iso() selected_label = site_label(stored) break save_cases(cases) message = f"已新增待辦:{title}" if reminder_count: message += f";已同步建立 {reminder_count} 筆行事曆提醒。" return task_rows(selected_label), build_hero_html(), case_rows(), upcoming_reminders(30), gr.update(choices=task_options(selected_label), value=None), message def complete_task(site, task_id): if not task_id: return task_rows(site), build_hero_html(), case_rows(), gr.update(choices=task_options(site), value=None), "請先選擇要完成的待辦。" selected_label = site updated_title = "" cases = load_cases() for stored in cases: for item in stored.get("tasks", []): if item.get("id") == task_id: item["status"] = "已完成" item["updated_at"] = now_iso() stored["updated_at"] = now_iso() selected_label = site_label(stored) updated_title = item.get("title", "") break if updated_title: break if not updated_title: return task_rows(site), build_hero_html(), case_rows(), gr.update(choices=task_options(site), value=None), "找不到這筆待辦,請重新整理清單。" save_cases(cases) return task_rows(selected_label), build_hero_html(), case_rows(), gr.update(choices=task_options(selected_label), value=None), f"已完成待辦:{updated_title}" def risk_rows(site=None): rows = [] selected_case_id = None if site: case = get_case_by_label(site) selected_case_id = case.get("id") if case else None for case in load_cases(): if selected_case_id and case.get("id") != selected_case_id: continue for item in case.get("risks", []): rows.append( { "案場": case.get("site_name", ""), "等級": item.get("level", ""), "狀態": item.get("status", ""), "風險": item.get("title", ""), "處置": item.get("mitigation", ""), "負責人": item.get("owner", ""), "更新時間": item.get("updated_at", item.get("created_at", "")), } ) level_rank = {"高": 0, "中": 1, "低": 2} rows.sort(key=lambda row: (level_rank.get(row["等級"], 9), row["狀態"] == "已解除")) return pd.DataFrame(rows) def risk_options(site=None, only_open=True): options = [] selected_case_id = None if site: case = get_case_by_label(site) selected_case_id = case.get("id") if case else None for case in load_cases(): if selected_case_id and case.get("id") != selected_case_id: continue for item in case.get("risks", []): if only_open and item.get("status") == "已解除": continue label = f"{case.get('site_name', '')}|{item.get('level', '')}|{item.get('status', '')}|{item.get('title', '')}" options.append((label, item.get("id", ""))) return options def add_risk(site, title, level, status, owner, mitigation): case = get_case_by_label(site) if not case: return risk_rows(), build_hero_html(), case_rows(), gr.update(choices=[]), "請先選擇案場。" if not str(title or "").strip(): return risk_rows(site), build_hero_html(), case_rows(), gr.update(choices=risk_options(site), value=None), "請輸入風險項目。" cases = load_cases() selected_label = site for stored in cases: if stored["id"] == case["id"]: stored.setdefault("risks", []).append( { "id": str(uuid.uuid4()), "title": str(title).strip(), "level": level or RISK_LEVELS[1], "status": status or RISK_STATUSES[0], "owner": str(owner or "").strip(), "mitigation": str(mitigation or "").strip(), "created_at": now_iso(), "updated_at": now_iso(), } ) stored["updated_at"] = now_iso() selected_label = site_label(stored) break save_cases(cases) return risk_rows(selected_label), build_hero_html(), case_rows(), gr.update(choices=risk_options(selected_label), value=None), f"已新增風險:{title}" def resolve_risk(site, risk_id): if not risk_id: return risk_rows(site), build_hero_html(), case_rows(), gr.update(choices=risk_options(site), value=None), "請先選擇要解除的風險。" selected_label = site updated_title = "" cases = load_cases() for stored in cases: for item in stored.get("risks", []): if item.get("id") == risk_id: item["status"] = "已解除" item["updated_at"] = now_iso() stored["updated_at"] = now_iso() selected_label = site_label(stored) updated_title = item.get("title", "") break if updated_title: break if not updated_title: return risk_rows(site), build_hero_html(), case_rows(), gr.update(choices=risk_options(site), value=None), "找不到這筆風險,請重新整理清單。" save_cases(cases) return risk_rows(selected_label), build_hero_html(), case_rows(), gr.update(choices=risk_options(selected_label), value=None), f"已解除風險:{updated_title}" def expense_rows(site=None): rows = [] selected_case_id = None if site: case = get_case_by_label(site) selected_case_id = case.get("id") if case else None for case in load_cases(): if selected_case_id and case.get("id") != selected_case_id: continue for item in case.get("expenses", []): rows.append( { "案場": case.get("site_name", ""), "日期": item.get("date", ""), "類型": item.get("type", ""), "項目": item.get("title", ""), "金額": money_text(item.get("amount", "")), "備註": item.get("notes", ""), } ) rows.sort(key=lambda row: row["日期"], reverse=True) return pd.DataFrame(rows) def budget_summary(site=None): cases = load_cases() if site: selected = get_case_by_label(site) cases = [case for case in cases if selected and case.get("id") == selected.get("id")] budget = sum(to_number(case.get("bid_amount")) for case in cases) spent = sum(to_number(item.get("amount")) for case in cases for item in case.get("expenses", [])) remaining = budget - spent ratio = round(spent / budget * 100) if budget > 0 else 0 return ( "### 預算彙整\n" f"- 預算/合約金額:{money_text(budget)}\n" f"- 已登錄支出:{money_text(spent)}\n" f"- 剩餘金額:{money_text(remaining)}\n" f"- 使用比例:{ratio}%" ) def add_expense(site, title, expense_type, amount, expense_date, notes): case = get_case_by_label(site) if not case: return expense_rows(), budget_summary(), build_hero_html(), case_rows(), "請先選擇案場。" if not str(title or "").strip(): return expense_rows(site), budget_summary(site), build_hero_html(), case_rows(), "請輸入支出項目。" amount_value = to_number(amount) if amount_value <= 0: return expense_rows(site), budget_summary(site), build_hero_html(), case_rows(), "請輸入大於 0 的金額。" try: expense_date = normalize_date_text(expense_date, "日期", allow_empty=False) except ValueError as exc: return expense_rows(site), budget_summary(site), build_hero_html(), case_rows(), str(exc) cases = load_cases() selected_label = site for stored in cases: if stored["id"] == case["id"]: stored.setdefault("expenses", []).append( { "id": str(uuid.uuid4()), "title": str(title).strip(), "type": expense_type or EXPENSE_TYPES[-1], "amount": str(amount_value), "date": expense_date, "notes": str(notes or "").strip(), "created_at": now_iso(), } ) stored["updated_at"] = now_iso() selected_label = site_label(stored) break save_cases(cases) return expense_rows(selected_label), budget_summary(selected_label), build_hero_html(), case_rows(), f"已新增支出:{title}" def attachment_rows(site=None): rows = [] selected_case_id = None if site: case = get_case_by_label(site) selected_case_id = case.get("id") if case else None for case in load_cases(): if selected_case_id and case.get("id") != selected_case_id: continue for item in case.get("attachments", []): rows.append( { "案場": case.get("site_name", ""), "時間": item.get("timestamp", ""), "類型": item.get("type", ""), "名稱": item.get("name", ""), "備註": item.get("notes", ""), "路徑": item.get("path", ""), } ) rows.sort(key=lambda row: row["時間"], reverse=True) return pd.DataFrame(rows) def add_attachment(site, files, attachment_type, notes): case = get_case_by_label(site) if not case: return attachment_rows(), build_hero_html(), case_rows(), "請先選擇案場。" if not files: return attachment_rows(site), build_hero_html(), case_rows(), "請先上傳檔案。" if isinstance(files, str): files = [files] cases = load_cases() added = 0 selected_label = site for stored in cases: if stored["id"] == case["id"]: for file_path in files: path = str(file_path) stored.setdefault("attachments", []).append( { "id": str(uuid.uuid4()), "timestamp": now_iso(), "type": attachment_type or "附件", "name": Path(path).name, "path": path, "notes": str(notes or "").strip(), } ) added += 1 stored["updated_at"] = now_iso() selected_label = site_label(stored) break save_cases(cases) return attachment_rows(selected_label), build_hero_html(), case_rows(), f"已加入 {added} 個附件。" def parse_reminder_datetime(value): if isinstance(value, dt.datetime): if value.tzinfo: value = value.astimezone(LOCAL_TZ).replace(tzinfo=None) return value.replace(tzinfo=None) value = str(value or "").strip() if not value: raise ValueError("請輸入提醒日期時間。") try: parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) if parsed.tzinfo: parsed = parsed.astimezone(LOCAL_TZ).replace(tzinfo=None) return parsed except ValueError: pass formats = ["%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%d"] for fmt in formats: try: parsed = dt.datetime.strptime(value, fmt) if fmt == "%Y-%m-%d": parsed = parsed.replace(hour=9, minute=0) return parsed except ValueError: continue raise ValueError("日期格式請用 YYYY-MM-DD HH:MM,例如 2026-06-30 09:00。") def reminder_status(event_time): now = now_local() if event_time < now: return "已過期" if event_time.date() == now.date(): return "今日" if event_time <= now + dt.timedelta(days=7): return "7 天內" return "待辦" def add_reminder(site, title, reminder_type, start_time, duration_minutes, remind_before, owner, notes): case = get_case_by_label(site) if not case: return pd.DataFrame([]), "請先選擇案場。", build_hero_html(), case_rows() if not str(title or "").strip(): return reminder_rows(site), "請輸入提醒標題。", build_hero_html(), case_rows() try: event_time = parse_reminder_datetime(start_time) except ValueError as exc: return reminder_rows(site), str(exc), build_hero_html(), case_rows() duration = max(parse_minutes(duration_minutes, 60), 1) remind_before = max(parse_minutes(remind_before, 0), 0) reminder = { "id": str(uuid.uuid4()), "title": str(title).strip(), "type": reminder_type or "其他", "start_at": event_time.isoformat(), "end_at": (event_time + dt.timedelta(minutes=duration)).isoformat(), "remind_before_minutes": remind_before, "owner": str(owner or "").strip(), "notes": str(notes or "").strip(), "created_at": now_iso(), } cases = load_cases() for stored in cases: if stored["id"] == case["id"]: stored.setdefault("reminders", []).append(reminder) stored["updated_at"] = now_iso() break save_cases(cases) return reminder_rows(site), f"已加入提醒:{title}", build_hero_html(), case_rows() def reminder_rows(site=None, horizon_days=None): rows = [] selected_case_id = None if site: case = get_case_by_label(site) selected_case_id = case.get("id") if case else None now = now_local() horizon = now + dt.timedelta(days=int(horizon_days)) if horizon_days else None for case in load_cases(): if selected_case_id and case.get("id") != selected_case_id: continue for item in case.get("reminders", []): try: start = parse_iso_local(item.get("start_at", "")) except ValueError: continue if horizon and (start < now or start > horizon): continue rows.append( { "狀態": reminder_status(start), "時間": start.strftime("%Y-%m-%d %H:%M"), "案場": case.get("site_name", ""), "類型": item.get("type", ""), "標題": item.get("title", ""), "負責人": item.get("owner", ""), "提前提醒": f"{item.get('remind_before_minutes', 0)} 分鐘", "備註": item.get("notes", ""), } ) rows.sort(key=lambda row: row["時間"]) return pd.DataFrame(rows) def upcoming_reminders(days): return reminder_rows(horizon_days=days) def ics_escape(value): return str(value or "").replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;") def ics_datetime(value): if isinstance(value, str): value = parse_iso_local(value) return value.strftime("%Y%m%dT%H%M%S") def fold_ics_line(line): chunks = [] while len(line.encode("utf-8")) > 73: cut = 73 while len(line[:cut].encode("utf-8")) > 73: cut -= 1 chunks.append(line[:cut]) line = " " + line[cut:] chunks.append(line) return "\r\n".join(chunks) def export_calendar_ics(site=None): cases = load_cases() selected_case = get_case_by_label(site) if site else None if selected_case: cases = [case for case in cases if case.get("id") == selected_case.get("id")] lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//NeilLog//Project Reminders//ZH-TW", "CALSCALE:GREGORIAN", "METHOD:PUBLISH", "X-WR-CALNAME:Neil日誌 專案提醒", ] event_count = 0 generated = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") for case in cases: for item in case.get("reminders", []): try: start = parse_iso_local(item.get("start_at", "")) end = parse_iso_local(item.get("end_at", "")) if item.get("end_at") else start + dt.timedelta(hours=1) except ValueError: continue event_count += 1 uid = f"{item.get('id', uuid.uuid4())}@ch-01" summary = f"{case.get('site_name', '')}|{item.get('title', '')}" description = ( f"案場:{case.get('site_name', '')}\\n" f"地點:{case.get('location', '')}\\n" f"類型:{item.get('type', '')}\\n" f"負責人:{item.get('owner', '')}\\n" f"備註:{item.get('notes', '')}" ) lines.extend( [ "BEGIN:VEVENT", f"UID:{ics_escape(uid)}", f"DTSTAMP:{generated}", f"DTSTART:{ics_datetime(start)}", f"DTEND:{ics_datetime(end)}", f"SUMMARY:{ics_escape(summary)}", f"LOCATION:{ics_escape(case.get('location', ''))}", f"DESCRIPTION:{ics_escape(description)}", "BEGIN:VALARM", "ACTION:DISPLAY", f"DESCRIPTION:{ics_escape(summary)}", f"TRIGGER:-PT{int(item.get('remind_before_minutes', 0))}M", "END:VALARM", "END:VEVENT", ] ) lines.append("END:VCALENDAR") if event_count == 0: raise gr.Error("目前沒有可匯出的提醒。") path = Path(tempfile.gettempdir()) / f"NeilLog_project_calendar_{now_local():%Y%m%d_%H%M%S}.ics" content = "\r\n".join(fold_ics_line(line) for line in lines) + "\r\n" path.write_bytes(content.encode("utf-8")) return str(path) def call_hf_json(model, payload=None, file_path=None, timeout=90): if not HF_TOKEN: raise RuntimeError("尚未設定 HUGGINGFACEHUB_API_TOKEN 或 HF_TOKEN。") if not model: raise RuntimeError("尚未設定可用的模型名稱。") url = f"{HF_API_URL}/{model}" if file_path: with open(file_path, "rb") as file: response = requests.post(url, headers=HEADERS, data=file.read(), timeout=timeout) else: response = requests.post(url, headers=HEADERS, json=payload or {}, timeout=timeout) response.raise_for_status() return response.json() def extract_generated_text(data): if isinstance(data, list) and data: return data[0].get("generated_text") or data[0].get("summary_text") or str(data[0]) if isinstance(data, dict): return data.get("generated_text") or data.get("text") or str(data) return str(data) def ai_mode_text(): if HF_TOKEN and TEXT_MODEL: return "完整 AI 模式:已啟用模型摘要與外部辨識" if HF_TOKEN: return "AI 轉錄模式:錄音/圖片/文件辨識可用,摘要使用本機規則" return "免 Token 模式:手動逐字稿、摘要、待辦、提醒與風險標註可用" def split_meeting_sentences(text): parts = re.split(r"[\n。;;]+", str(text or "")) return [part.strip(" -•\t") for part in parts if part.strip(" -•\t")] def extract_datetime_from_text(text): text = str(text or "") patterns = [ r"\d{4}[/-]\d{1,2}[/-]\d{1,2}\s*(?:上午|早上|中午|下午|晚上)?\s*\d{0,2}(?:點|時|:\d{1,2})?", r"\d{1,2}[/-]\d{1,2}\s*(?:上午|早上|中午|下午|晚上)?\s*\d{0,2}(?:點|時|:\d{1,2})?", ] for pattern in patterns: match = re.search(pattern, text) if not match: continue value = match.group(0).strip() if not re.match(r"\d{4}", value): value = f"{now_local().year}/{value}" try: return normalize_task_datetime(value, "會議日期", allow_empty=False) except ValueError: continue return "" def local_meeting_minutes(case, title, participants, focus, transcript): sentences = split_meeting_sentences(transcript) summary_items = sentences[:5] or ["尚未提供足夠文字,請補充逐字稿或會議重點。"] decision_items = [s for s in sentences if any(k in s for k in ("決議", "確認", "同意", "完成", "送審", "報價"))][:6] risk_items = [s for s in sentences if any(k in s for k in ("風險", "延誤", "逾期", "問題", "未回", "缺"))][:6] action_items = [s for s in sentences if any(k in s for k in ("請", "待辦", "追蹤", "確認", "完成", "提交", "回覆", "送審", "報價"))][:8] lines = [ f"## {title}", f"- 案場:{case.get('site_name')} / {case.get('location')}", f"- 參與人員:{participants or '未填'}", f"- 會議重點:{focus or '未填'}", "", "### 會議摘要", *(f"- {item}" for item in summary_items), "", "### 重要事項 / 決議", *(f"- {item}" for item in (decision_items or ["尚未偵測到明確決議。"])), "", "### 待辦清單", *(f"- {item}" for item in (action_items or ["尚未偵測到明確待辦。"])), "", "### 風險與需追蹤事項", *(f"- {item}" for item in (risk_items or ["尚未偵測到明確風險。"])), "", "### 下一步建議", "- 請確認每個待辦的負責人與期限。", "- 有日期的事項會自動建立提醒。", ] return "\n".join(lines) def infer_owner_from_text(text, participants): match = re.search(r"(?:負責人|窗口|owner|Owner)\s*[::]\s*([^,,。\n]+)", str(text or "")) if match: return match.group(1).strip() first = str(participants or "").replace("、", ",").replace(",", ",").split(",")[0].strip() return first def build_meeting_intelligence(case, title, participants, focus, transcript, minutes): text = "\n".join([str(focus or ""), str(transcript or ""), str(minutes or "")]) sentences = split_meeting_sentences(text) important_keywords = ("決議", "重要", "必須", "需要", "請", "待辦", "追蹤", "確認", "完成", "提交", "回覆", "期限", "風險", "報價", "送審", "會議", "現勘", "important", "confirm", "submit", "reply", "deadline", "follow") action_keywords = ("請", "待辦", "追蹤", "確認", "完成", "提交", "回覆", "送審", "報價", "please", "todo", "follow", "confirm", "complete", "submit", "reply", "quote") risk_keywords = ("風險", "延誤", "逾期", "卡住", "未回", "缺", "問題", "異常", "追加", "risk", "delay", "late", "issue", "missing", "not returned") important = [] actions = [] risks = [] seen = set() for sentence in sentences: compact = sentence[:120] if compact in seen: continue seen.add(compact) lowered = sentence.lower() if any(keyword in lowered for keyword in important_keywords): important.append(compact) if any(keyword in lowered for keyword in action_keywords): actions.append( { "title": compact, "owner": infer_owner_from_text(sentence, participants), "due_at": extract_datetime_from_text(sentence), } ) if any(keyword in lowered for keyword in risk_keywords): risks.append(compact) if not important: important = [sentence[:120] for sentence in sentences[:3]] actions = actions[:6] risks = risks[:5] lines = [ "\n### 自動標註重點", *(f"- {item}" for item in important[:8]), "\n### 自動待辦與提醒", ] if actions: for item in actions: due = item.get("due_at") or "未偵測日期" owner = item.get("owner") or "未填負責人" lines.append(f"- {item['title']}|負責人:{owner}|日期:{due}") else: lines.append("- 尚未偵測到明確待辦,請在待辦頁補充。") lines.append("\n### 自動風險提示") lines.extend([f"- {item}" for item in risks] or ["- 尚未偵測到明確風險。"]) return "\n".join(lines), actions, risks def apply_meeting_intelligence(stored, actions, risks): task_count = 0 reminder_count = 0 risk_count = 0 for item in actions: title = item.get("title", "").strip() if not title: continue task_id = str(uuid.uuid4()) stored.setdefault("tasks", []).append( { "id": task_id, "title": title, "owner": item.get("owner", ""), "agreed_at": item.get("due_at", ""), "due_at": item.get("due_at", ""), "priority": "重要" if item.get("due_at") else "一般", "status": "未開始", "notes": "由會議轉錄自動建立", "created_at": now_iso(), "updated_at": now_iso(), } ) task_count += 1 start_iso = task_datetime_to_iso(item.get("due_at")) if start_iso: start_dt = dt.datetime.fromisoformat(start_iso) stored.setdefault("reminders", []).append( { "id": str(uuid.uuid4()), "title": f"會議待辦:{title[:40]}", "type": "追蹤待辦", "start_at": start_iso, "end_at": (start_dt + dt.timedelta(minutes=30)).isoformat(), "remind_before_minutes": 30, "owner": item.get("owner", ""), "notes": "由會議轉錄自動建立", "created_at": now_iso(), } ) reminder_count += 1 for item in risks[:3]: stored.setdefault("risks", []).append( { "id": str(uuid.uuid4()), "title": item, "level": "中", "status": "觀察中", "owner": "", "mitigation": "由會議轉錄偵測,請確認處置方式。", "created_at": now_iso(), "updated_at": now_iso(), } ) risk_count += 1 return task_count, reminder_count, risk_count def summarize_with_model(prompt, fallback): if not TEXT_MODEL: return fallback try: data = call_hf_json(TEXT_MODEL, {"inputs": prompt, "parameters": {"max_new_tokens": 700, "temperature": 0.2}}) return extract_generated_text(data) except Exception as exc: return f"{fallback}\n\n模型摘要暫時不可用:{exc}" def transcribe_audio(audio_path): if not audio_path: return "", "未提供音檔,將使用手動逐字稿。" if not HF_TOKEN: return "", "免 Token 模式:錄音自動轉文字需要 HF Token;請貼上逐字稿,系統仍會自動摘要、建立待辦與提醒。" try: data = call_hf_json(TRANSCRIBE_MODEL, file_path=audio_path, timeout=180) text = data.get("text", "") if isinstance(data, dict) else extract_generated_text(data) text = str(text or "").strip() if not text: return "", "轉譯完成,但沒有取得文字內容。" return text, "語音轉文字完成。" except Exception as exc: return "", f"語音轉文字暫時不可用:{exc}" def meeting_rows(site=None): rows = [] selected_case_id = None if site: case = get_case_by_label(site) selected_case_id = case.get("id") if case else None for case in load_cases(): if selected_case_id and case.get("id") != selected_case_id: continue for item in case.get("meetings", []): rows.append( { "時間": item.get("recorded_at", ""), "案場": case.get("site_name", ""), "主題": item.get("title", ""), "參與人員": item.get("participants", ""), "重點": item.get("focus", ""), "摘要": str(item.get("minutes", ""))[:160], } ) rows.sort(key=lambda row: row["時間"], reverse=True) return pd.DataFrame(rows) def add_meeting(site, title, participants, audio, focus, manual_transcript): case = get_case_by_label(site) if not case: return "請先選擇案場。", "", "", meeting_rows(), build_hero_html(), case_rows(), upcoming_reminders(30), task_rows(only_open=True), gr.update(choices=task_options(), value=None), risk_rows(), gr.update(choices=risk_options(), value=None) if not title: title = f"會議紀錄 {now_local():%Y-%m-%d}" transcript = str(manual_transcript or "").strip() status_parts = [] if transcript: status_parts.append("已使用手動逐字稿。") elif audio: transcript, transcribe_status = transcribe_audio(audio) status_parts.append(transcribe_status) else: status_parts.append("請錄音、上傳音檔,或貼上逐字稿。") if not transcript: return " ".join(status_parts), "", "", meeting_rows(site), build_hero_html(), case_rows(), upcoming_reminders(30), task_rows(only_open=True), gr.update(choices=task_options(), value=None), risk_rows(site), gr.update(choices=risk_options(site), value=None) fallback = local_meeting_minutes(case, title, participants, focus, transcript) prompt = ( "請將以下專案會議逐字稿整理成繁體中文會議紀錄,像 Notion AI 會議摘要一樣清楚。格式請包含:\n" "1. 會議重點摘要\n2. 重要事項\n3. 決議事項\n4. 待辦清單(負責人、日期/期限、狀態)\n5. 風險與需追蹤事項\n6. 下一步建議\n" f"案場:{case.get('site_name')} / {case.get('location')}\n" f"階段:{case.get('stage')}\n" f"參與人員:{participants}\n" f"補充重點:{focus}\n" f"逐字稿:{transcript}" ) minutes = summarize_with_model(prompt, fallback) intelligence, actions, risks = build_meeting_intelligence(case, title, participants, focus, transcript, minutes) minutes = f"{minutes}\n{intelligence}" cases = load_cases() task_count = reminder_count = risk_count = 0 selected_label = site for stored in cases: if stored["id"] == case["id"]: task_count, reminder_count, risk_count = apply_meeting_intelligence(stored, actions, risks) stored.setdefault("meetings", []).append( { "id": str(uuid.uuid4()), "title": title, "participants": participants or "", "recorded_at": now_iso(), "audio_path": audio or "", "transcript": transcript, "minutes": minutes, "focus": focus or "", "auto_tasks": task_count, "auto_reminders": reminder_count, "auto_risks": risk_count, } ) stored["updated_at"] = now_iso() selected_label = site_label(stored) break save_cases(cases) status_parts.append(f"已產生摘要並存入案場;自動建立 {task_count} 項待辦、{reminder_count} 筆提醒、{risk_count} 筆風險提示。") return ( " ".join(status_parts), transcript, minutes, meeting_rows(selected_label), build_hero_html(), case_rows(), upcoming_reminders(30), task_rows(selected_label), gr.update(choices=task_options(selected_label), value=None), risk_rows(selected_label), gr.update(choices=risk_options(selected_label), value=None), ) def case_summary(case): return ( f"### {case.get('site_name', '未命名案場')}\n" f"- 地點:{case.get('location', '')}\n" f"- Google Maps:{google_maps_markdown(case.get('location', ''))}\n" f"- 客戶:{case.get('client', '')}\n" f"- 類別:{case.get('category', '')}\n" f"- 階段:{case.get('stage', '')}\n" f"- 金額/預算:{case.get('bid_amount', '')}\n" f"- 標籤:{', '.join(case.get('tags', [])) or '無'}\n" f"- 會議:{len(case.get('meetings', []))} 筆\n" f"- 照片:{len(case.get('photo_history', []))} 筆\n" f"- 文件:{len(case.get('ocr_documents', [])) + len(case.get('attachments', []))} 筆\n" f"- 提醒:{len(case.get('reminders', []))} 筆\n" f"- 未完成待辦:{sum(1 for item in case.get('tasks', []) if item.get('status') != '已完成')} 項\n" f"- 未解除風險:{sum(1 for item in case.get('risks', []) if item.get('status') != '已解除')} 項\n" f"- 已登錄支出:{money_text(sum(to_number(item.get('amount')) for item in case.get('expenses', [])))}\n" f"- 管理備註:{case.get('manager_notes', '') or '未填'}" ) def get_dashboard(site): case = get_case_by_label(site) if not case: return "請先選擇案場。" latest_meeting = case.get("meetings", [])[-1].get("minutes", "尚無會議紀錄。") if case.get("meetings") else "尚無會議紀錄。" return ( f"{case_summary(case)}\n\n" "### PM 檢核\n" "- 確認下一個里程碑、負責人與期限是否清楚。\n" "- 檢查投標/預算文件是否已歸檔。\n" "- 對未更新超過 7 天的案場安排追蹤。\n\n" f"### 最新會議\n{latest_meeting}" ) def get_case_detail(site): case = get_case_by_label(site) if not case: return "請先選擇案場。" lines = [case_summary(case), "", "### 附件"] attachments = case.get("attachments", []) if not attachments: lines.append("尚無附件。") for item in attachments[:20]: lines.append(f"- {item.get('name', Path(item.get('path', '')).name)}") if len(attachments) > 20: lines.append(f"- 另有 {len(attachments) - 20} 筆附件未顯示") lines.append("\n### 會議紀錄") for meeting in case.get("meetings", [])[-5:]: lines.append(f"- {meeting.get('recorded_at', '')}|{meeting.get('title', '')}|{meeting.get('participants', '')}") if not case.get("meetings"): lines.append("尚無會議紀錄。") lines.append("\n### 行事曆提醒") for reminder in case.get("reminders", [])[-8:]: lines.append(f"- {reminder.get('start_at', '')[:16].replace('T', ' ')}|{reminder.get('type', '')}|{reminder.get('title', '')}") if not case.get("reminders"): lines.append("尚無提醒。") lines.append("\n### 待辦任務") for task in case.get("tasks", [])[-10:]: agreed = task.get("agreed_at") or "未填約定" due = task.get("due_at") or "未填期限" lines.append(f"- {task.get('status', '')}|{task.get('priority', '')}|約定:{agreed}|期限:{due}|{task.get('owner', '')}|{task.get('title', '')}") if not case.get("tasks"): lines.append("尚無待辦。") lines.append("\n### 風險控管") for risk in case.get("risks", [])[-10:]: lines.append(f"- {risk.get('level', '')}|{risk.get('status', '')}|{risk.get('title', '')}|{risk.get('mitigation', '')}") if not case.get("risks"): lines.append("尚無風險紀錄。") lines.append("\n### 預算支出") for expense in case.get("expenses", [])[-10:]: lines.append(f"- {expense.get('date', '')}|{expense.get('type', '')}|{expense.get('title', '')}|{money_text(expense.get('amount', ''))}") if not case.get("expenses"): lines.append("尚無支出紀錄。") return "\n".join(lines) def scan_photo(site, image_path, notes): case = get_case_by_label(site) if not case: return "請先選擇案場。", "", pd.DataFrame([]), [], build_hero_html(), case_rows() if not image_path: return "請先上傳照片。", "", photo_history(site), gallery(site), build_hero_html(), case_rows() if not HF_TOKEN: caption = f"免 Token 模式:照片已儲存,請以備註作為現場紀錄。檔名:{Path(image_path).name}" else: try: caption_data = call_hf_json(IMAGE_CAPTION_MODEL, file_path=image_path) caption = extract_generated_text(caption_data) except Exception as exc: caption = f"照片辨識暫時不可用,已改用備註整理:{exc}" fallback = ( f"照片已加入 {case.get('site_name')}。\n" f"- 系統描述:{caption}\n" f"- 現場備註:{notes or '未填'}\n" "- 建議:補充拍攝位置、缺失項目、改善期限。" ) prompt = ( "請根據案場照片描述與現場備註,整理繁體中文現場紀錄、可能風險與追蹤事項。\n" f"案場:{case.get('site_name')}\n照片描述:{caption}\n備註:{notes}" ) summary = summarize_with_model(prompt, fallback) save_photo_record(case["id"], image_path, caption, summary, notes) return caption, summary, photo_history(site), gallery(site), build_hero_html(), case_rows() def save_photo_record(case_id, image_path, caption, summary, notes): cases = load_cases() for case in cases: if case["id"] == case_id: case.setdefault("photo_history", []).append( { "id": str(uuid.uuid4()), "timestamp": now_iso(), "image_path": image_path, "caption": caption, "summary": summary, "notes": notes or "", } ) case["updated_at"] = now_iso() break save_cases(cases) def scan_document(site, image_path, notes): case = get_case_by_label(site) if not case: return "請先選擇案場。", "", "", pd.DataFrame([]), build_hero_html(), case_rows() if not image_path: return "請先上傳文件影像。", "", "", ocr_history(site), build_hero_html(), case_rows() if not HF_TOKEN: ocr_text = str(notes or "").strip() or f"免 Token 模式:文件已儲存,請在文件備註貼上文字或摘要。檔名:{Path(image_path).name}" else: try: ocr_data = call_hf_json(OCR_MODEL, file_path=image_path) ocr_text = extract_generated_text(ocr_data) except Exception as exc: ocr_text = f"文件辨識暫時不可用,已改用備註整理:{exc}" fallback = ( f"文件已加入 {case.get('site_name')}。\n" f"- 辨識文字:{ocr_text[:300]}\n" f"- 備註:{notes or '未填'}\n" "- 建議:確認文件類型、日期、金額與簽核狀態。" ) prompt = ( "請將以下文件辨識文字整理成專案文件摘要,標出日期、金額、待辦與風險。\n" f"案場:{case.get('site_name')}\n辨識文字:{ocr_text}\n備註:{notes}" ) summary = summarize_with_model(prompt, fallback) save_ocr_record(case["id"], image_path, ocr_text, summary, notes) return "已完成文件紀錄。", ocr_text, summary, ocr_history(site), build_hero_html(), case_rows() def save_ocr_record(case_id, image_path, ocr_text, summary, notes): cases = load_cases() for case in cases: if case["id"] == case_id: case.setdefault("ocr_documents", []).append( { "id": str(uuid.uuid4()), "timestamp": now_iso(), "image_path": image_path, "ocr_text": ocr_text, "summary": summary, "notes": notes or "", } ) case["updated_at"] = now_iso() break save_cases(cases) def photo_history(site): case = get_case_by_label(site) rows = [] for item in (case or {}).get("photo_history", []): rows.append({"時間": item.get("timestamp", ""), "照片": item.get("image_path", ""), "描述": item.get("caption", ""), "摘要": item.get("summary", "")}) return pd.DataFrame(rows) def ocr_history(site): case = get_case_by_label(site) rows = [] for item in (case or {}).get("ocr_documents", []): text = item.get("ocr_text", "") rows.append({"時間": item.get("timestamp", ""), "文件": item.get("image_path", ""), "辨識文字": text[:120], "摘要": item.get("summary", "")}) return pd.DataFrame(rows) def gallery(site): case = get_case_by_label(site) return [[item.get("image_path"), item.get("caption", "")] for item in (case or {}).get("photo_history", [])] def search_ocr(query): query = str(query or "").strip().lower() if not query: return pd.DataFrame([]) rows = [] for case in load_cases(): for item in case.get("ocr_documents", []): haystack = " ".join([item.get("ocr_text", ""), item.get("summary", ""), item.get("notes", "")]).lower() if query in haystack: rows.append( { "案場": case.get("site_name", ""), "時間": item.get("timestamp", ""), "文件": item.get("image_path", ""), "摘要": item.get("summary", ""), } ) return pd.DataFrame(rows) def generate_gantt(site): if not HAS_MATPLOTLIB: return None case = get_case_by_label(site) if not case: return None font_path = find_font() font_props = None if font_path: from matplotlib import font_manager font_props = font_manager.FontProperties(fname=font_path) created = parse_iso_local(case.get("created_at", now_iso())) tasks = [] if case.get("meetings"): for meeting in case["meetings"]: start = parse_iso_local(meeting.get("recorded_at", now_iso())) tasks.append((meeting.get("title", "會議"), start, start + dt.timedelta(days=1))) else: tasks = [ ("前期評估", created, created + dt.timedelta(days=7)), ("投標/預算", created + dt.timedelta(days=7), created + dt.timedelta(days=21)), ("設計與文件", created + dt.timedelta(days=21), created + dt.timedelta(days=45)), ("施工追蹤", created + dt.timedelta(days=45), created + dt.timedelta(days=75)), ("驗收結案", created + dt.timedelta(days=75), created + dt.timedelta(days=90)), ] fig, ax = plt.subplots(figsize=(10, max(4, len(tasks) * 0.65))) for idx, (name, start, end) in enumerate(tasks): ax.barh(idx, mdates.date2num(end) - mdates.date2num(start), left=mdates.date2num(start), height=0.5) ax.set_yticks(range(len(tasks))) ax.set_yticklabels([task[0] for task in tasks], fontproperties=font_props) ax.xaxis_date() ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d")) ax.set_title(case.get("site_name", "案場進度"), fontproperties=font_props) ax.grid(axis="x", alpha=0.25) fig.tight_layout() path = Path(tempfile.gettempdir()) / f"gantt_{case['id']}.png" fig.savefig(path, dpi=160, bbox_inches="tight") plt.close(fig) return str(path) def safe_filename(value): cleaned = "".join(ch for ch in str(value) if ch.isalnum() or ch in (" ", "-", "_")).strip() return cleaned or "case" def find_font(): candidates = [ "/usr/share/fonts/truetype/noto/NotoSansCJKtc-Regular.otf", "/usr/share/fonts/opentype/noto/NotoSansCJKtc-Regular.otf", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.otf", "C:/Windows/Fonts/kaiu.ttf", "C:/Windows/Fonts/NotoSansTC-VF.ttf", "C:/Windows/Fonts/NotoSansHK-VF.ttf", "C:/Windows/Fonts/msjh.ttc", "C:/Windows/Fonts/mingliu.ttc", ] return next((path for path in candidates if Path(path).exists() and Path(path).suffix.lower() in {".ttf", ".otf"}), None) def pdf_safe_text(text, has_unicode_font): text = str(text) if has_unicode_font: return text return text.encode("latin-1", errors="replace").decode("latin-1") def export_report(site): case = get_case_by_label(site) if not case: raise gr.Error("請先選擇案場。") stamp = now_local().strftime("%Y%m%d_%H%M%S") base = safe_filename(case.get("site_name", "case")) pdf_path = Path(tempfile.gettempdir()) / f"{base}_{stamp}.pdf" excel_path = Path(tempfile.gettempdir()) / f"{base}_{stamp}.xlsx" pdf_output = None if HAS_FPDF: pdf = FPDF() pdf.set_auto_page_break(auto=True, margin=15) pdf.add_page() font = find_font() if font: with warnings.catch_warnings(): warnings.simplefilter("ignore") pdf.add_font("CJK", "", font, uni=True) pdf.set_font("CJK", size=14) else: pdf.set_font("Arial", size=12) has_unicode_font = bool(font) pdf.multi_cell(0, 8, pdf_safe_text(f"{APP_TITLE}\n\n{case_summary(case).replace('### ', '')}", has_unicode_font)) pdf.ln(4) pdf.multi_cell(0, 8, pdf_safe_text("近期會議", has_unicode_font)) for meeting in case.get("meetings", []): pdf.multi_cell(0, 7, pdf_safe_text(f"- {meeting.get('recorded_at', '')} {meeting.get('title', '')}\n{meeting.get('minutes', '')[:500]}", has_unicode_font)) pdf.ln(4) pdf.multi_cell(0, 8, pdf_safe_text("待辦任務", has_unicode_font)) for task in case.get("tasks", []): pdf.multi_cell(0, 7, pdf_safe_text(f"- {task.get('status', '')} {task.get('priority', '')} 約定:{task.get('agreed_at', '')} 期限:{task.get('due_at', '')} {task.get('title', '')}", has_unicode_font)) pdf.ln(4) pdf.multi_cell(0, 8, pdf_safe_text("風險控管", has_unicode_font)) for risk in case.get("risks", []): pdf.multi_cell(0, 7, pdf_safe_text(f"- {risk.get('level', '')} {risk.get('status', '')} {risk.get('title', '')}:{risk.get('mitigation', '')}", has_unicode_font)) pdf.output(str(pdf_path)) pdf_output = str(pdf_path) sheets = { "案場": pd.DataFrame([case]).drop(columns=["meetings", "photo_history", "ocr_documents", "attachments", "reminders", "tasks", "risks", "expenses"], errors="ignore"), "會議": pd.DataFrame(case.get("meetings", [])), "照片": pd.DataFrame(case.get("photo_history", [])), "文件": pd.DataFrame(case.get("ocr_documents", [])), "附件": pd.DataFrame(case.get("attachments", [])), "提醒": pd.DataFrame(case.get("reminders", [])), "待辦": pd.DataFrame(case.get("tasks", [])), "風險": pd.DataFrame(case.get("risks", [])), "支出": pd.DataFrame(case.get("expenses", [])), } try: with pd.ExcelWriter(excel_path) as writer: for sheet_name, frame in sheets.items(): frame.to_excel(writer, index=False, sheet_name=sheet_name) data_output = str(excel_path) except Exception: zip_path = Path(tempfile.gettempdir()) / f"{base}_{stamp}_csv.zip" with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive: for sheet_name, frame in sheets.items(): archive.writestr(f"{sheet_name}.csv", frame.to_csv(index=False).encode("utf-8-sig")) data_output = str(zip_path) return pdf_output, data_output def verify_login(user, password, key): key_ok = not APP_ACCESS_KEY or key == APP_ACCESS_KEY password_ok = not LOGIN_PASSWORD or password == LOGIN_PASSWORD if user == LOGIN_USER and password_ok and key_ok: return "登入成功。", gr.update(visible=False), gr.update(visible=True) return "登入失敗,請確認帳號、密碼或存取金鑰。", gr.update(visible=True), gr.update(visible=False) def format_now_zh(): now = now_local() return f"{now.year} 年 {now.month} 月 {now.day} 日 {now.hour:02d}:{now.minute:02d}:{now.second:02d}(台灣時間)" def today_progress_report(cases): today = today_local() due_today = [] overdue = [] task_due_today = [] task_overdue = [] updated_today = [] meetings_today = [] documents_total = 0 photos_total = 0 open_tasks = 0 high_risks = 0 budget_total = 0 expense_total = 0 for case in cases: documents_total += len(case.get("ocr_documents", [])) + len(case.get("attachments", [])) photos_total += len(case.get("photo_history", [])) budget_total += to_number(case.get("bid_amount")) expense_total += sum(to_number(item.get("amount")) for item in case.get("expenses", [])) open_tasks += sum(1 for item in case.get("tasks", []) if item.get("status") != "已完成") high_risks += sum(1 for item in case.get("risks", []) if item.get("level") == "高" and item.get("status") != "已解除") try: if case.get("updated_at") and parse_iso_local(case["updated_at"]).date() == today: updated_today.append(case.get("site_name", "未命名案場")) except ValueError: pass for meeting in case.get("meetings", []): try: if parse_iso_local(meeting.get("recorded_at", "")).date() == today: meetings_today.append(f"{case.get('site_name', '')}:{meeting.get('title', '會議')}") except ValueError: pass for item in case.get("reminders", []): try: start = parse_iso_local(item.get("start_at", "")) except ValueError: continue label = f"{case.get('site_name', '')}:{item.get('title', '')}" if start.date() == today: due_today.append(label) elif start < now_local(): overdue.append(label) for item in case.get("tasks", []): if item.get("status") == "已完成" or not item.get("due_at"): continue try: due = dt.datetime.strptime(item.get("due_at", "")[:10], "%Y-%m-%d").date() except ValueError: continue label = f"{case.get('site_name', '')}:{item.get('title', '')}" if due == today: task_due_today.append(label) elif due < today: task_overdue.append(label) if not cases: summary = "目前尚無案場資料。請先新增案場,系統會自動把案場、提醒、會議、文件彙整到這裡。" elif due_today or task_due_today: summary = f"今日有 {len(due_today)} 項提醒、{len(task_due_today)} 項待辦需要處理,請優先確認負責人與期限。" elif overdue or task_overdue: summary = f"目前有 {len(overdue)} 項逾期提醒、{len(task_overdue)} 項逾期待辦,建議先完成追蹤。" elif high_risks: summary = f"目前有 {high_risks} 項高風險案場,請確認處置策略與負責人。" else: summary = "今日沒有到期提醒,請維持案場資料更新並檢查下週節點。" return { "summary": summary, "due_today": due_today, "overdue": overdue, "task_due_today": task_due_today, "task_overdue": task_overdue, "updated_today": updated_today, "meetings_today": meetings_today, "documents_total": documents_total, "photos_total": photos_total, "open_tasks": open_tasks, "high_risks": high_risks, "budget_total": budget_total, "expense_total": expense_total, } def build_action_cards(case_count, reminder_count, document_count, photo_count, open_tasks, high_risks): cards = [ ("案場優先", f"{case_count} 件案場", "整理階段、客戶與預算資訊", "teal"), ("今日提醒", f"{reminder_count} 項提醒", "同步手機與平板行事曆", "amber"), ("待辦任務", f"{open_tasks} 項未完成", "確認期限、優先級與負責人", "violet"), ("風險控管", f"{high_risks} 項高風險", "追蹤處置策略與解除狀態", "rose"), ] return "".join( f"""
{title} {value}

{desc}

""" for title, value, desc, tone in cards ) def system_health_report(): checks = [] issues = [] try: load_cases() checks.append(f"資料檔正常:{storage_mode()}") checks.append(f"紀錄時間:{APP_TIMEZONE}") except Exception as exc: issues.append(f"資料檔異常:{exc}") checks.append(ai_mode_text()) if HAS_MATPLOTLIB: checks.append("甘特圖套件正常") else: issues.append("甘特圖套件未啟用") if HAS_FPDF: checks.append("PDF 套件正常") else: checks.append("PDF 套件未啟用,報表會自動改用資料檔") status = "系統健康" if not issues else f"自動偵錯:{len(issues)} 項注意" detail_parts = checks[:3] + issues detail = ";".join(detail_parts) return status, detail def build_hero_html(): cases = load_cases() case_count = len(cases) completion = 0 if cases: filled = 0 total = case_count * 4 for case in cases: filled += bool(case.get("site_name")) filled += bool(case.get("location")) filled += bool(case.get("stage")) filled += bool(case.get("category")) completion = round(filled / total * 100) report = today_progress_report(cases) due_count = len(report["due_today"]) overdue_count = len(report["overdue"]) task_due_count = len(report["task_due_today"]) budget_ratio = round(report["expense_total"] / report["budget_total"] * 100) if report["budget_total"] > 0 else 0 health_text, health_detail = system_health_report() return f"""
Neil您好 {format_now_zh()}
{health_text}
{health_detail}
今日進度報告與彙整

{report["summary"]}

今日關注 {due_count + task_due_count}
案場{case_count}
今日提醒{due_count}
今日待辦{task_due_count}
逾期追蹤{overdue_count + len(report['task_overdue'])}
未完成待辦{report["open_tasks"]}
高風險{report["high_risks"]}
資料完成{completion}%
預算使用{budget_ratio}%
""" CSS = """ :root { --navy-950: #040b16; --navy-900: #071327; --navy-800: #0b2342; --navy-700: #0f3767; --cyan-400: #55d7ff; --cyan-500: #25a9e8; --gold-400: #eba020; --gold-200: #ffe7ad; --cream: #fff3d8; --ink: #f8fbff; --muted: #8ea0b8; --glass: rgba(17, 43, 77, 0.74); --glass-2: rgba(255, 255, 255, 0.10); --line: rgba(142, 160, 184, 0.26); } html, body, .gradio-container, .main, gradio-app { background: radial-gradient(circle at 8% 0%, rgba(85, 215, 255, 0.18), transparent 30%), radial-gradient(circle at 88% 8%, rgba(235, 160, 32, 0.13), transparent 28%), linear-gradient(135deg, #10233b 0%, #101420 48%, #1b2238 100%) !important; color: var(--ink) !important; } .gradio-container { max-width: 1440px !important; padding: 10px 18px 32px !important; } .app-window { margin-top: 22px; border-radius: 28px 28px 10px 10px; overflow: hidden; border: 1px solid rgba(105, 130, 178, 0.38); background: linear-gradient(145deg, rgba(9,19,39,0.96), rgba(9,28,55,0.88)); box-shadow: 0 34px 90px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.10); } .window-bar { height: 58px; display: flex; align-items: center; justify-content: space-between; padding: 0 28px; background: #050c18; border-bottom: 1px solid rgba(255,255,255,0.06); } .window-dots { display: flex; gap: 10px; } .dot { width: 11px; height: 11px; border-radius: 999px; display: block; } .dot.red { background: #ff6b65; } .dot.yellow { background: #ffc861; } .dot.green { background: #66e2bf; } .brand-mark { color: rgba(248,251,255,0.42); font-size: 12px; font-weight: 800; letter-spacing: 4px; } .visual-frame { min-height: 184px; display: grid; grid-template-columns: 72px 1fr; background: linear-gradient(90deg, rgba(52,119,184,0.16), transparent 28%), linear-gradient(135deg, #0d2b50 0%, #061327 58%, #05101f 100%); } .side-rail { border-right: 1px solid rgba(255,255,255,0.07); background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)); display: flex; flex-direction: column; align-items: center; padding-top: 28px; gap: 22px; } .rail-logo { width: 42px; height: 42px; border-radius: 10px; background: linear-gradient(135deg, #71e0ff, #28a9f0); color: #061327; display: flex; align-items: center; justify-content: center; font-weight: 900; } .rail-line { width: 28px; height: 6px; border-radius: 99px; background: rgba(142,160,184,0.42); } .hero-board { padding: 34px 38px 36px; } .hero-head { display: flex; justify-content: space-between; align-items: center; gap: 20px; } .hero-title { margin: 0; color: #f8fbff; font-size: 24px; font-weight: 900; } .live-pill { padding: 8px 16px; border-radius: 999px; background: rgba(39, 223, 183, 0.15); color: #65f2ce; font-size: 12px; font-weight: 900; letter-spacing: 1px; } .metric-grid { display: grid; grid-template-columns: repeat(3, minmax(150px, 1fr)); gap: 14px; margin-top: 24px; } .metric-card { min-height: 96px; border-radius: 14px; padding: 18px; border: 1px solid rgba(142,160,184,0.18); background: linear-gradient(145deg, rgba(255,255,255,0.10), rgba(255,255,255,0.04)); } .metric-card span { color: #8ea0b8; font-size: 12px; font-weight: 800; } .metric-card strong { display: block; color: #f8fbff; font-size: 38px; margin-top: 8px; line-height: 1; } .bar-card { margin-top: 26px; border-radius: 14px; background: rgba(8, 43, 82, 0.66); padding: 28px 20px 20px; } .bar-chart { height: 96px; display: flex; align-items: end; gap: 12px; } .bar-chart i { flex: 1; min-width: 36px; border-radius: 7px 7px 0 0; background: linear-gradient(180deg, #63dcff 0%, #1974c1 100%); box-shadow: 0 -8px 18px rgba(85,215,255,0.16); } .pm-shell { gap: 18px !important; padding: 18px 0 0 !important; } .pm-title, .pm-panel, .block, .form, .panel, .wrap { border-radius: 14px !important; } .pm-title, .pm-panel { border: 1px solid rgba(105, 130, 178, 0.26) !important; background: linear-gradient(145deg, rgba(255,255,255,0.11), rgba(255,255,255,0.04)), rgba(8, 22, 43, 0.76) !important; box-shadow: 0 22px 54px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255,255,255,0.12) !important; backdrop-filter: blur(18px) saturate(130%) !important; -webkit-backdrop-filter: blur(18px) saturate(130%) !important; } .pm-title { padding: 20px 24px !important; display: none !important; } .pm-panel { padding: 18px !important; } .pm-title h1, h1, h2, h3, label, .label-wrap span, p, li { color: var(--ink) !important; letter-spacing: 0 !important; } .pm-title h1 { color: var(--gold-200) !important; margin-bottom: 2px !important; } .pm-title p { color: #dceaff !important; margin-top: 0 !important; } .block, .form, .panel, .wrap { background: rgba(255, 255, 255, 0.07) !important; border-color: rgba(142, 160, 184, 0.18) !important; box-shadow: none !important; } input, textarea, select, .dropdown, .secondary-wrap { background: rgba(246, 250, 255, 0.94) !important; color: var(--navy-950) !important; border-color: rgba(105, 130, 178, 0.28) !important; border-radius: 12px !important; } button, .gr-button { border-radius: 12px !important; border: 1px solid rgba(85, 215, 255, 0.28) !important; background: linear-gradient(180deg, #63dcff 0%, #2d91d6 48%, #155ca4 100%) !important; color: #061327 !important; font-weight: 800 !important; box-shadow: 0 14px 28px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255,255,255,0.42) !important; } button:hover, .gr-button:hover { filter: brightness(1.08) !important; transform: translateY(-1px); } .tab-nav, .tabs, .tabitem { background: transparent !important; border-color: rgba(142,160,184,0.18) !important; } .tab-nav button, [role="tab"] { background: transparent !important; color: #eff6ff !important; border: 0 !important; box-shadow: none !important; border-radius: 0 !important; } .tab-nav button.selected, [role="tab"][aria-selected="true"] { color: var(--gold-200) !important; background: transparent !important; border-bottom: 3px solid var(--gold-400) !important; } table { background: rgba(255, 255, 255, 0.95) !important; color: #071327 !important; } th { background: #071327 !important; color: #55d7ff !important; } td { color: #071327 !important; } .prose, .markdown, .gradio-markdown { color: var(--ink) !important; } .erp-shell { min-height: 680px; display: grid; grid-template-columns: 248px 1fr; overflow: hidden; border-radius: 28px; background: #f8f0e1; border: 1px solid rgba(255,255,255,0.22); box-shadow: 0 34px 90px rgba(0, 0, 0, 0.34); } .erp-sidebar { padding: 28px 22px; background: linear-gradient(180deg, rgba(21, 60, 97, 0.20), transparent 34%), linear-gradient(180deg, #2a4760 0%, #28304f 45%, #2d223e 100%); color: #fff; display: flex; flex-direction: column; gap: 22px; } .studio-mark { width: 72px; height: 72px; border-radius: 20px; display: flex; align-items: center; justify-content: center; font-size: 36px; font-weight: 900; color: #172138; background: radial-gradient(circle at 30% 20%, #9be8ff 0%, #3baeea 44%, #18395f 100%); box-shadow: 0 18px 40px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.45); } .brand-block span { display: block; color: #ffd978; font-weight: 900; letter-spacing: 3px; font-size: 13px; } .brand-block strong { display: block; margin-top: 8px; color: #fff; font-size: 26px; line-height: 1.05; } .brand-block small { display: block; margin-top: 10px; color: rgba(255,255,255,0.58); font-size: 14px; font-weight: 800; } .side-menu { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; } .side-menu .menu-title { color: rgba(255,255,255,0.42); font-weight: 900; font-size: 14px; } .side-menu b { display: block; padding: 13px 14px; border-radius: 12px; color: rgba(255,255,255,0.86); background: rgba(255,255,255,0.08); font-size: 15px; } .side-menu b:first-of-type { color: #fff; background: rgba(255,255,255,0.14); border: 1px solid rgba(255,255,255,0.12); box-shadow: inset 0 1px 0 rgba(255,255,255,0.15); } .online-card { margin-top: auto; border-radius: 16px; padding: 14px; background: rgba(255,255,255,0.12); border: 1px solid rgba(255,255,255,0.12); } .online-card strong { display: block; color: #fff; font-size: 18px; } .online-card span { display: block; color: rgba(255,255,255,0.62); margin-top: 6px; } .erp-main { padding: 34px 40px 36px; background: radial-gradient(circle at 100% 8%, rgba(255, 214, 132, 0.32), transparent 22%), linear-gradient(180deg, #fff7e7 0%, #f5ead9 100%); color: #2f2330; } .topline { display: flex; justify-content: space-between; align-items: center; padding-bottom: 22px; border-bottom: 1px solid rgba(73, 54, 72, 0.12); } .topline .hello { display: block; color: #e45b7e; font-weight: 900; letter-spacing: 4px; font-size: 13px; } .topline strong { display: block; margin-top: 8px; color: #2f2330; font-size: 26px; } .health { display: flex; align-items: center; gap: 10px; color: rgba(47,35,48,0.72); font-weight: 900; } .health i { width: 10px; height: 10px; border-radius: 99px; background: #58d0a6; box-shadow: 0 0 0 8px rgba(88,208,166,0.16); } .pulse-card { min-height: 310px; margin-top: 24px; border-radius: 28px; padding: 42px 46px; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(280px, 0.85fr); gap: 28px; overflow: hidden; background: radial-gradient(circle at 100% 0%, rgba(85, 215, 255, 0.18), transparent 30%), radial-gradient(circle at 0% 100%, rgba(255, 216, 120, 0.16), transparent 32%), linear-gradient(135deg, #233a5a 0%, #17304f 48%, #33233e 100%); color: #fff; box-shadow: 0 26px 70px rgba(80, 55, 45, 0.26); } .pulse-copy span { color: #ffd36d !important; font-weight: 900; letter-spacing: 4px; font-size: 14px; } .pulse-copy h1 { margin: 16px 0 18px !important; color: #fff !important; font-size: clamp(32px, 4.2vw, 54px); line-height: 1.12; max-width: 720px; } .pulse-copy h1 em { color: #69d8ff; font-style: normal; } .pulse-copy p { color: rgba(255,255,255,0.86) !important; font-size: 18px; line-height: 1.7; max-width: 620px; } .hero-tags { display: flex; gap: 10px; margin-top: 28px; flex-wrap: wrap; } .hero-tags b { display: inline-flex; align-items: center; min-height: 34px; padding: 8px 13px; border-radius: 999px; color: #f8fbff; background: rgba(255,255,255,0.12); border: 1px solid rgba(255,255,255,0.16); font-size: 13px; font-weight: 900; } .focus-panel { align-self: stretch; border-radius: 24px; padding: 26px; background: rgba(255,255,255,0.13); border: 1px solid rgba(255,255,255,0.20); box-shadow: inset 0 1px 0 rgba(255,255,255,0.18); backdrop-filter: blur(14px); } .focus-head { display: flex; align-items: end; justify-content: space-between; gap: 18px; padding-bottom: 22px; border-bottom: 1px solid rgba(255,255,255,0.16); } .focus-head span { color: rgba(255,255,255,0.76); font-weight: 900; } .focus-head strong { color: #ffd36d; font-size: 76px; line-height: 0.86; } .focus-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-top: 22px; } .focus-list div { border-radius: 16px; padding: 16px; background: rgba(255,255,255,0.12); border: 1px solid rgba(255,255,255,0.12); } .focus-list span { display: block; color: rgba(255,255,255,0.72); font-size: 13px; font-weight: 900; } .focus-list strong { display: block; margin-top: 8px; color: #fff; font-size: 30px; } .report-strip { margin-top: 20px; padding: 16px; border-radius: 18px; background: rgba(255,255,255,0.84); border: 1px solid rgba(42,71,96,0.12); box-shadow: 0 14px 30px rgba(48, 43, 56, 0.08); } .report-strip ul { list-style: none; display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; padding: 0; margin: 0; } .report-strip li { color: #2e3a4d !important; display: flex; justify-content: space-between; gap: 12px; align-items: center; font-weight: 900; min-height: 48px; padding: 0 12px; border-radius: 12px; background: rgba(246, 249, 252, 0.84); } .report-strip strong { color: #1468a8; font-size: 18px; } .action-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-top: 20px; } .action-card { min-height: 132px; border-radius: 18px; padding: 20px; color: #263247; overflow: hidden; position: relative; border: 1px solid rgba(42,71,96,0.12); box-shadow: 0 14px 30px rgba(48, 43, 56, 0.10); } .action-card strong { display: block; margin-top: 12px; font-size: 20px; color: #17243a; } .action-card span, .action-card p { display: block; color: rgba(38,50,71,0.72) !important; margin: 7px 0 0; } .card-arrow { width: 34px; height: 34px; border-radius: 10px; display: flex; align-items: center; justify-content: center; color: #fff; background: #1268a9; font-size: 22px; } .action-card.teal { background: linear-gradient(145deg, #eefbf8, #d5f3ee); } .action-card.amber { background: linear-gradient(145deg, #fff7df, #f8e5ad); } .action-card.violet { background: linear-gradient(145deg, #f0f0ff, #dadcfb); } .action-card.rose { background: linear-gradient(145deg, #fff0f4, #f8d9e3); } .data-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-top: 20px; } .data-strip div { border-radius: 16px; padding: 18px; background: rgba(255,255,255,0.86); border: 1px solid rgba(42,71,96,0.12); box-shadow: 0 14px 30px rgba(48, 43, 56, 0.08); } .data-strip span { display: block; color: rgba(38,50,71,0.62); font-weight: 900; } .data-strip strong { display: block; margin-top: 8px; color: #153d64; font-size: 32px; } .pm-shell { background: #f5ead9; padding: 24px 0 0 !important; } .pm-panel { background: rgba(255,255,255,0.88) !important; color: #2f2330 !important; border: 1px solid rgba(42,71,96,0.12) !important; box-shadow: 0 16px 36px rgba(48,43,56,0.09) !important; } .pm-panel label, .pm-panel p, .pm-panel li, .pm-panel h1, .pm-panel h2, .pm-panel h3 { color: #182235 !important; } .pm-shell, .pm-shell .block, .pm-shell .form, .pm-shell .panel, .pm-shell .wrap { color: #182235 !important; } .pm-shell h1, .pm-shell h2, .pm-shell h3, .pm-shell h4, .pm-shell p, .pm-shell li, .pm-shell label, .pm-shell .label-wrap span, .pm-shell .prose, .pm-shell .markdown, .pm-shell .gradio-markdown { color: #182235 !important; } .pm-shell label, .pm-shell .label-wrap span { color: #4a5568 !important; font-weight: 800 !important; } .pm-shell input, .pm-shell textarea, .pm-shell select, .pm-shell .dropdown, .pm-shell .secondary-wrap { background: #f7fafc !important; color: #111827 !important; border: 1px solid rgba(42,71,96,0.18) !important; } .pm-shell input::placeholder, .pm-shell textarea::placeholder { color: #718096 !important; opacity: 1 !important; } .pm-shell [role="tablist"], .pm-shell .tab-nav { background: #f4ead8 !important; border-bottom: 1px solid rgba(42,71,96,0.12) !important; } .pm-shell [role="tab"], .pm-shell .tab-nav button { color: #34445c !important; font-weight: 900 !important; opacity: 1 !important; } .pm-shell [role="tab"][aria-selected="true"], .pm-shell .tab-nav button.selected { color: #0f5f9f !important; border-bottom: 4px solid #ff7a1a !important; } .pm-shell button, .pm-shell .gr-button { color: #061327 !important; background: linear-gradient(180deg, #5fdcff 0%, #2496d8 48%, #1267ad 100%) !important; border-color: rgba(18, 103, 173, 0.42) !important; text-shadow: none !important; } .pm-shell button.primary, .pm-shell .gr-button.primary { color: #04111f !important; font-weight: 900 !important; } .pm-shell .pm-panel { box-shadow: 0 18px 44px rgba(31, 41, 55, 0.12) !important; } .pm-shell .block, .pm-shell .form, .pm-shell .panel, .pm-shell .wrap { background: rgba(255,255,255,0.62) !important; border-color: rgba(42,71,96,0.12) !important; } .pm-shell table, .pm-shell th, .pm-shell td { color: #111827 !important; } .pm-shell { gap: 12px !important; padding-top: 14px !important; } .pm-shell .pm-panel { padding: 16px !important; border-radius: 16px !important; } .pm-shell .block, .pm-shell .form, .pm-shell .panel, .pm-shell .wrap { padding: 0 !important; margin: 0 0 12px 0 !important; background: transparent !important; border: 0 !important; box-shadow: none !important; } .pm-shell .form { gap: 12px !important; } .pm-shell label, .pm-shell .label-wrap { margin-bottom: 6px !important; min-height: 0 !important; } .pm-shell label, .pm-shell .label-wrap span { font-size: 14px !important; line-height: 1.35 !important; } .pm-shell input, .pm-shell textarea, .pm-shell select, .pm-shell .dropdown, .pm-shell .secondary-wrap { min-height: 42px !important; height: auto !important; border-radius: 12px !important; box-shadow: inset 0 1px 0 rgba(255,255,255,0.72) !important; } .pm-shell .dropdown > div, .pm-shell .secondary-wrap > div, .pm-shell [data-testid="dropdown"] > div { background: transparent !important; border: 0 !important; box-shadow: none !important; min-height: 40px !important; } .pm-shell .dropdown input, .pm-shell .secondary-wrap input { min-height: 38px !important; padding: 6px 10px !important; } .pm-shell textarea { min-height: 92px !important; } .pm-shell .wrap > div, .pm-shell .block > div, .pm-shell .form > div { border-radius: 12px !important; } .pm-shell button, .pm-shell .gr-button { min-height: 44px !important; padding: 10px 16px !important; border-radius: 12px !important; box-shadow: 0 10px 22px rgba(18, 103, 173, 0.20) !important; } .pm-shell .row { gap: 16px !important; } .pm-shell .gap { gap: 12px !important; } .command-dashboard { margin: 12px 0 0; padding: 18px; border-radius: 18px; background: linear-gradient(135deg, rgba(255,255,255,0.96), rgba(248, 252, 255, 0.90)); border: 1px solid rgba(30, 64, 105, 0.14); box-shadow: 0 18px 44px rgba(15, 23, 42, 0.16); color: #111827; } .command-header { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid rgba(30, 64, 105, 0.12); } .command-header span { display: block; color: #0f5f9f; font-weight: 900; font-size: 14px; } .command-header strong { display: block; margin-top: 4px; color: #111827; font-size: 24px; } .command-status { display: inline-flex; align-items: center; gap: 9px; padding: 8px 12px; border-radius: 999px; color: #1f2937; background: #ecfdf5; border: 1px solid #bbf7d0; font-weight: 900; } .command-status i { width: 9px; height: 9px; border-radius: 999px; background: #10b981; } .command-summary { display: grid; grid-template-columns: minmax(0, 1fr) 150px; gap: 16px; align-items: stretch; padding: 16px 0; } .summary-copy { padding: 16px; border-radius: 14px; background: #f8fafc; border: 1px solid rgba(30, 64, 105, 0.10); } .summary-copy b { display: block; color: #111827; font-size: 18px; } .summary-copy p { margin: 8px 0 0 !important; color: #374151 !important; line-height: 1.6; } .summary-number { border-radius: 14px; background: linear-gradient(135deg, #0f5f9f, #123b63); color: #fff; display: flex; flex-direction: column; justify-content: center; align-items: center; } .summary-number span { color: rgba(255,255,255,0.78); font-weight: 900; } .summary-number strong { color: #fff; font-size: 52px; line-height: 1; } .command-kpis { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 10px; } .command-kpis div { min-height: 78px; padding: 14px; border-radius: 12px; background: #ffffff; border: 1px solid rgba(30, 64, 105, 0.10); } .command-kpis span { display: block; color: #64748b; font-weight: 900; font-size: 13px; } .command-kpis strong { display: block; margin-top: 8px; color: #0f172a; font-size: 26px; } .gradio-container { max-width: 1360px !important; } .pm-shell { background: #f3eadc !important; } .pm-shell [role="tabpanel"] { padding-top: 18px !important; } .pm-shell [role="tablist"], .pm-shell .tab-nav { position: sticky; top: 0; z-index: 10; display: flex !important; gap: 4px !important; overflow-x: auto !important; padding: 8px 10px !important; background: rgba(255, 248, 236, 0.96) !important; backdrop-filter: blur(14px); } .pm-shell [role="tab"], .pm-shell .tab-nav button { min-height: 38px !important; padding: 8px 12px !important; border-radius: 10px !important; border-bottom: 0 !important; color: #334155 !important; white-space: nowrap !important; } .pm-shell [role="tab"][aria-selected="true"], .pm-shell .tab-nav button.selected { color: #ffffff !important; background: #0f5f9f !important; border-bottom: 0 !important; } .pm-shell .pm-panel { background: rgba(255,255,255,0.94) !important; border-radius: 14px !important; border-color: rgba(30, 64, 105, 0.12) !important; box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08) !important; } .pm-shell button, .pm-shell .gr-button { background: #0f5f9f !important; border: 1px solid #0f5f9f !important; color: #fff !important; box-shadow: none !important; } .pm-shell button.secondary, .pm-shell .gr-button.secondary { background: #e2e8f0 !important; border-color: #cbd5e1 !important; color: #1e293b !important; } .pm-shell input, .pm-shell textarea, .pm-shell select, .pm-shell .dropdown, .pm-shell .secondary-wrap { background: #ffffff !important; border-color: #cbd5e1 !important; } .pm-shell table { border-radius: 12px !important; overflow: hidden !important; } .pm-shell th { background: #f1f5f9 !important; color: #0f172a !important; } .pm-shell td { border-color: #e2e8f0 !important; } .gradio-container, html, body, .main, gradio-app { background: radial-gradient(circle at 12% 0%, rgba(91, 196, 255, 0.18), transparent 30%), radial-gradient(circle at 92% 8%, rgba(255, 208, 112, 0.24), transparent 28%), linear-gradient(135deg, #f7fbff 0%, #fff8ea 48%, #eef8f5 100%) !important; color: #102033 !important; } .command-dashboard { background: linear-gradient(135deg, rgba(255,255,255,0.98), rgba(244, 252, 255, 0.94)) !important; border-color: rgba(20, 118, 180, 0.16) !important; box-shadow: 0 16px 38px rgba(56, 95, 130, 0.14) !important; } .command-header span { color: #0f8abf !important; } .summary-copy { background: #ffffff !important; } .summary-number { background: linear-gradient(135deg, #2eb7df, #2184c2) !important; } .command-kpis div { background: linear-gradient(180deg, #ffffff, #f7fbff) !important; border-color: rgba(20, 118, 180, 0.13) !important; } .command-kpis div:nth-child(2), .command-kpis div:nth-child(5) { background: linear-gradient(180deg, #fffdf7, #fff3cf) !important; } .command-kpis div:nth-child(3), .command-kpis div:nth-child(6) { background: linear-gradient(180deg, #f8fffd, #dcf7ef) !important; } .command-kpis div:nth-child(4), .command-kpis div:nth-child(8) { background: linear-gradient(180deg, #fbfdff, #e4f4ff) !important; } .command-kpis strong { color: #135f8f !important; } .pm-shell { background: linear-gradient(180deg, #fff7e8, #f4fbff) !important; } .pm-shell [role="tablist"], .pm-shell .tab-nav { background: rgba(255, 252, 244, 0.98) !important; } .pm-shell [role="tab"][aria-selected="true"], .pm-shell .tab-nav button.selected { background: linear-gradient(135deg, #25a9e8, #1386c8) !important; } .pm-shell .pm-panel { background: rgba(255,255,255,0.96) !important; } .pm-shell button, .pm-shell .gr-button { background: linear-gradient(135deg, #35c4ee, #1687cf) !important; border-color: rgba(22, 135, 207, 0.50) !important; min-height: 46px !important; border-radius: 12px !important; font-weight: 950 !important; white-space: normal !important; line-height: 1.25 !important; } .pm-shell button:not(.primary), .pm-shell .gr-button:not(.primary) { background: #edf2f7 !important; border-color: #cbd5e1 !important; color: #1e293b !important; } .pm-shell button.primary, .pm-shell .gr-button.primary { background: linear-gradient(135deg, #14b8d5, #0b76bd) !important; color: #ffffff !important; } .erp-shell { background: #fff8eb !important; } .erp-sidebar { background: radial-gradient(circle at 20% 0%, rgba(92, 207, 255, 0.34), transparent 34%), linear-gradient(180deg, #f9fcff 0%, #eaf7ff 48%, #fff3d9 100%) !important; color: #18324a !important; border-right: 1px solid rgba(20, 118, 180, 0.14) !important; } .brand-block strong, .brand-block small, .side-menu b, .online-card strong, .online-card span { color: #18324a !important; } .brand-block span, .side-menu .menu-title { color: #0f8abf !important; } .side-menu b { background: rgba(255,255,255,0.76) !important; border: 1px solid rgba(20, 118, 180, 0.10) !important; } .side-menu b:first-of-type { background: linear-gradient(135deg, #25a9e8, #1386c8) !important; color: #ffffff !important; } .online-card { background: rgba(255,255,255,0.78) !important; border-color: rgba(20, 118, 180, 0.12) !important; } .erp-main { background: radial-gradient(circle at 100% 0%, rgba(255, 211, 109, 0.25), transparent 26%), linear-gradient(180deg, #fffaf0 0%, #f5fbff 100%) !important; } .pulse-card { background: radial-gradient(circle at 95% 0%, rgba(255, 215, 116, 0.25), transparent 34%), linear-gradient(135deg, #f2fbff 0%, #e7f6ff 48%, #fff3d8 100%) !important; color: #18324a !important; } .pulse-copy h1, .pulse-copy p, .pulse-copy span, .focus-head span, .focus-list span, .focus-list strong { color: #18324a !important; } .pulse-copy h1 em, .focus-head strong { color: #0f8abf !important; } .focus-panel, .focus-list div, .hero-tags b { background: rgba(255,255,255,0.72) !important; border-color: rgba(20, 118, 180, 0.14) !important; color: #18324a !important; } .meeting-panel { align-self: stretch; } .meeting-panel textarea { line-height: 1.65 !important; } .meeting-steps { margin: 0 0 14px 0 !important; padding: 14px 16px !important; border-radius: 14px !important; background: #ffffff !important; border: 1px solid rgba(20, 118, 180, 0.16) !important; color: #18324a !important; font-weight: 900 !important; } .meeting-steps p { margin: 4px 0 !important; color: #18324a !important; } .meeting-action button, .meeting-action .gr-button { min-height: 58px !important; font-size: 20px !important; font-weight: 950 !important; border-radius: 14px !important; background: linear-gradient(135deg, #16b4df, #0f76bd) !important; } .meeting-clear button, .meeting-clear .gr-button { min-height: 46px !important; background: #e2e8f0 !important; border-color: #cbd5e1 !important; color: #1e293b !important; font-weight: 900 !important; } .meeting-audio, .meeting-audio .wrap, .meeting-audio .block { background: #f8fcff !important; border: 1px solid rgba(20, 118, 180, 0.14) !important; border-radius: 14px !important; min-height: 138px !important; overflow: visible !important; } .meeting-audio button, .meeting-audio .gr-button { min-height: 42px !important; min-width: 52px !important; padding: 8px 14px !important; border-radius: 10px !important; background: #ffffff !important; color: #0f172a !important; border: 1px solid #cbd5e1 !important; box-shadow: none !important; } .meeting-audio button svg, .meeting-audio .gr-button svg { color: #0f172a !important; fill: currentColor !important; stroke: currentColor !important; opacity: 1 !important; display: inline-block !important; } .meeting-audio button:empty, .meeting-audio .gr-button:empty { display: none !important; } .meeting-audio audio { width: 100% !important; } .pm-upload, .pm-upload .wrap, .pm-upload .block { background: #f8fcff !important; border: 1px solid rgba(20, 118, 180, 0.14) !important; border-radius: 14px !important; overflow: visible !important; } .pm-upload button, .pm-upload .gr-button { min-height: 42px !important; min-width: 64px !important; padding: 8px 14px !important; border-radius: 10px !important; background: #ffffff !important; color: #0f172a !important; border: 1px solid #cbd5e1 !important; box-shadow: none !important; } .pm-upload button svg, .pm-upload .gr-button svg { color: #0f172a !important; fill: currentColor !important; stroke: currentColor !important; opacity: 1 !important; display: inline-block !important; } .pm-upload button:empty, .pm-upload .gr-button:empty { display: none !important; } .pm-upload [aria-label="Clear"], .pm-upload [aria-label="Remove"], .pm-upload [title="Clear"], .pm-upload [title="Remove"] { background: #fee2e2 !important; border-color: #fecaca !important; color: #991b1b !important; } .pm-guide { margin: 0 0 12px 0 !important; padding: 10px 14px !important; border-radius: 12px !important; background: linear-gradient(135deg, #f0fbff, #fff9e8) !important; border: 1px solid rgba(20, 118, 180, 0.14) !important; color: #27445f !important; font-weight: 700 !important; } .pm-guide p { margin: 0 !important; color: #27445f !important; } .health-line { margin: 6px 0 14px; padding: 9px 12px; border-radius: 10px; background: #ffffff; border: 1px solid rgba(20, 118, 180, 0.14); color: #1e3a56; font-weight: 800; } .pm-shell label, .pm-shell .label-wrap span, .pm-shell .block-info, .pm-shell .prose, .pm-shell .markdown, .pm-shell .gradio-markdown, .pm-shell .gradio-markdown *, .pm-shell textarea, .pm-shell input, .pm-shell select, .pm-shell [role="textbox"], .pm-shell [role="combobox"], .pm-shell [data-testid="textbox"], .pm-shell [data-testid="dropdown"], .pm-shell table, .pm-shell th, .pm-shell td { color: #0f172a !important; opacity: 1 !important; text-shadow: none !important; } .pm-shell input::placeholder, .pm-shell textarea::placeholder { color: #64748b !important; opacity: 1 !important; } .pm-shell [role="tab"] { color: #1f2937 !important; font-weight: 900 !important; } .pm-shell [role="tab"][aria-selected="true"] { color: #ffffff !important; } .pm-shell .wrap, .pm-shell .block, .pm-shell .form, .pm-shell .input-container { color: #0f172a !important; } @media (max-width: 900px) { .gradio-container { padding: 8px !important; } .command-dashboard { padding: 14px; border-radius: 14px; } .command-header { align-items: flex-start; flex-direction: column; } .command-header strong { font-size: 20px; } .command-summary { grid-template-columns: 1fr; } .summary-number { min-height: 110px; } .command-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } .erp-shell { grid-template-columns: 1fr; } .erp-sidebar { min-height: auto; padding: 18px; display: grid; grid-template-columns: 64px 1fr; align-items: center; } .studio-mark { width: 56px; height: 56px; font-size: 28px; border-radius: 16px; } .brand-block strong { font-size: 22px; } .side-menu, .online-card { display: none; } .erp-main { padding: 22px 16px 24px; } .topline { align-items: flex-start; gap: 16px; flex-direction: column; } .topline strong { font-size: 22px; } .pulse-card { grid-template-columns: 1fr; padding: 26px 20px; min-height: 0; } .pulse-copy h1 { font-size: 32px; } .pulse-copy p { font-size: 16px; } .focus-head strong { font-size: 58px; } .focus-list, .report-strip ul, .action-grid, .data-strip { grid-template-columns: 1fr; } .pm-title, .pm-panel { padding: 14px !important; } } @media (min-width: 901px) and (max-width: 1180px) { .command-kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); } .erp-shell { grid-template-columns: 220px 1fr; } .erp-sidebar { padding: 24px 18px; } .brand-block strong { font-size: 23px; } .erp-main { padding: 30px 28px; } .pulse-card { grid-template-columns: 1fr; } .focus-list { grid-template-columns: repeat(4, 1fr); } .action-grid { grid-template-columns: repeat(2, 1fr); } } """ PWA_HEAD = f""" """ with gr.Blocks(title=APP_TITLE) as demo: gr.HTML(f"") dashboard_home = gr.HTML(build_hero_html()) with gr.Column(elem_classes="pm-shell"): with gr.Column(elem_classes="pm-title"): gr.Markdown(f"# {APP_TITLE}\n案場、待辦、會議轉錄、提醒與文件報表集中管理。") with gr.Column(elem_classes="pm-panel", visible=False) as login_area: gr.Markdown("### 登入") login_user = gr.Textbox(label="帳號", value=LOGIN_USER, lines=1) login_pass = gr.Textbox(label="密碼", type="password", lines=1) login_key = gr.Textbox(label="存取金鑰", type="password", lines=1) login_button = gr.Button("登入", variant="primary") login_message = gr.Textbox(label="狀態", interactive=False) with gr.Column(visible=True) as main_area: with gr.Tab("專案工作台"): gr.Markdown("### 案場工作台") gr.Markdown(GUIDE_TEXT["workbench"], elem_classes="pm-guide") with gr.Row(): with gr.Column(scale=1, elem_classes="pm-panel"): workbench_site = gr.Dropdown(label="選擇案場", choices=list_site_options()) load_workbench_btn = gr.Button("查看這個案場狀態", variant="primary") workbench_stage = gr.Dropdown(label="目前階段", choices=CASE_STAGES, value=CASE_STAGES[0]) workbench_category = gr.Dropdown(label="專案類別", choices=current_categories(), value=current_categories()[0]) workbench_budget = gr.Textbox(label="預算/金額") workbench_notes = gr.Textbox(label="PM 備註與下一步", lines=5) pm_note_template_btn = gr.Button("帶入 PM 備註範本") save_workbench_btn = gr.Button("儲存案場狀態與下一步", variant="primary") gr.ClearButton([workbench_budget, workbench_notes], value="清空工作台輸入") workbench_message = gr.Textbox(label="更新狀態", interactive=False) with gr.Column(scale=2, elem_classes="pm-panel"): workbench_summary = gr.Markdown("### 請選擇案場\n載入後會顯示案場摘要、提醒、會議、文件與 PM 備註。") with gr.Tab("待辦與風險"): gr.Markdown("### 待辦與風險控管") gr.Markdown(GUIDE_TEXT["task"], elem_classes="pm-guide") with gr.Row(): with gr.Column(scale=1, elem_classes="pm-panel"): gr.Markdown("#### 快速建立案場") with gr.Row(): quick_task_site_name = gr.Textbox(label="案場名稱", placeholder="例如:光寶A棟") quick_task_site_location = gr.Textbox(label="地點", placeholder="可空白") quick_task_map_preview = gr.Markdown(location_map_preview("")) quick_task_site_btn = gr.Button("建立這個案場並開始新增待辦", variant="primary") gr.ClearButton([quick_task_site_name, quick_task_site_location], value="清空快速案場") task_site = gr.Dropdown(label="待辦案場", choices=list_site_options()) task_title = gr.Textbox(label="待辦事項", placeholder="例如:確認施工圖、追蹤報價、補齊送審文件") task_owner = gr.Textbox(label="負責人") task_agreed = gr.DateTime(label="約定時間", include_time=True, type="string", timezone="Asia/Taipei") task_due = gr.DateTime(label="完成期限", include_time=True, type="string", timezone="Asia/Taipei") task_priority = gr.Dropdown(label="優先級", choices=TASK_PRIORITIES, value=TASK_PRIORITIES[0]) task_status = gr.Dropdown(label="狀態", choices=TASK_STATUSES, value=TASK_STATUSES[0]) task_sync_calendar = gr.Checkbox(label="同步建立行事曆提醒", value=True) task_notes = gr.Textbox(label="待辦備註", lines=3) add_task_btn = gr.Button("建立待辦並同步提醒", variant="primary") gr.ClearButton([task_title, task_owner, task_agreed, task_due, task_notes], value="清空待辦表單") task_message = gr.Textbox(label="待辦狀態", interactive=False) with gr.Column(scale=2, elem_classes="pm-panel"): task_table = gr.Dataframe(value=task_rows(only_open=True), label="未完成待辦", interactive=False, wrap=True) task_picker = gr.Dropdown(label="選擇要完成的待辦", choices=task_options()) with gr.Row(): task_selected_btn = gr.Button("此案場待辦") task_all_btn = gr.Button("全部未完成") complete_task_btn = gr.Button("把選到的待辦標記完成", variant="primary") with gr.Row(): with gr.Column(scale=1, elem_classes="pm-panel"): risk_site = gr.Dropdown(label="風險案場", choices=list_site_options()) risk_title = gr.Textbox(label="風險項目", placeholder="例如:工期延誤、報價未回、圖說待確認") risk_level = gr.Dropdown(label="風險等級", choices=RISK_LEVELS, value=RISK_LEVELS[1]) risk_status = gr.Dropdown(label="處理狀態", choices=RISK_STATUSES, value=RISK_STATUSES[0]) risk_owner = gr.Textbox(label="負責人") risk_mitigation = gr.Textbox(label="處置方式", lines=3) risk_template_btn = gr.Button("帶入風險處置範本") add_risk_btn = gr.Button("新增風險並追蹤處置", variant="primary") gr.ClearButton([risk_title, risk_owner, risk_mitigation], value="清空風險表單") risk_message = gr.Textbox(label="風險狀態", interactive=False) with gr.Column(scale=2, elem_classes="pm-panel"): risk_table = gr.Dataframe(value=risk_rows(), label="風險清單", interactive=False, wrap=True) risk_picker = gr.Dropdown(label="選擇要解除的風險", choices=risk_options()) with gr.Row(): risk_selected_btn = gr.Button("此案場風險") risk_all_btn = gr.Button("全部風險") resolve_risk_btn = gr.Button("把選到的風險標記解除", variant="primary") with gr.Tab("案場總覽"): gr.Markdown("### 案場總覽") gr.Markdown(GUIDE_TEXT["cases"], elem_classes="pm-guide") with gr.Row(): with gr.Column(scale=1, elem_classes="pm-panel"): site_name = gr.Textbox(label="案場名稱") location = gr.Textbox(label="地點") location_map = gr.Markdown(location_map_preview("")) client = gr.Textbox(label="客戶") stage = gr.Dropdown(label="階段", choices=CASE_STAGES, value=CASE_STAGES[0]) category = gr.Dropdown(label="類別", choices=current_categories(), value=current_categories()[0]) new_category = gr.Textbox(label="新增類別") tags = gr.Textbox(label="標籤", placeholder="例如:投標, 設計, 急件") bid_amount = gr.Textbox(label="金額/預算") manager_notes = gr.Textbox(label="管理備註", lines=4) add_site_btn = gr.Button("建立案場並加入清單", variant="primary") gr.ClearButton([site_name, location, client, new_category, tags, bid_amount, manager_notes], value="清空案場表單") site_message = gr.Textbox(label="狀態", interactive=False) with gr.Column(scale=2, elem_classes="pm-panel"): category_filter = gr.Dropdown(label="類別篩選", choices=[ALL_CATEGORIES] + current_categories(), value=ALL_CATEGORIES) case_table = gr.Dataframe(value=case_rows(), interactive=False, wrap=True) overview = gr.Markdown(category_overview()) storage_status = gr.Markdown(data_storage_status()) with gr.Row(): export_data_btn = gr.Button("下載 Neil日誌資料備份", variant="primary") restore_data_file = gr.File(label="選擇備份 JSON 還原", file_count="single") restore_data_btn = gr.Button("還原備份到系統") data_backup_file = gr.File(label="資料備份檔") with gr.Tab("會議紀錄"): gr.Markdown("### 會議轉錄與自動摘要") gr.Markdown(GUIDE_TEXT["meeting"], elem_classes="pm-guide") gr.Markdown( "1. 先選案場與填會議主題。\n" "2. 錄音、上傳音檔,或直接貼逐字稿。\n" "3. 按「開始整理會議」,系統會產生摘要、重要事項、待辦、提醒與風險提示。", elem_classes="meeting-steps", ) with gr.Row(): with gr.Column(scale=1, elem_classes="pm-panel meeting-panel"): meeting_site = gr.Dropdown(label="1. 選擇案場", choices=list_site_options()) meeting_title = gr.Textbox(label="2. 會議主題", placeholder="例如:週會、現場協調、報價確認") participants = gr.Textbox(label="3. 參與人員", placeholder="例如:Neil、設計師、現場主任") focus = gr.Textbox(label="4. 這次要追蹤的重點", lines=3, placeholder="例如:決議、風險、期限、誰要做什麼") meeting_template_btn = gr.Button("套用會議重點範本") audio_input = gr.Audio(sources=["microphone", "upload"], type="filepath", label="5A. 錄音或上傳音檔", elem_classes="meeting-audio") manual_transcript = gr.Textbox(label="5B. 沒有音檔就貼逐字稿", lines=6, placeholder="可直接貼上會議文字,系統一樣會整理摘要、待辦與提醒。") meeting_btn = gr.Button("6. 開始整理會議", variant="primary", elem_classes="meeting-action") gr.ClearButton([meeting_title, participants, audio_input, focus, manual_transcript], value="清空重填", elem_classes="meeting-clear") meeting_status = gr.Textbox(label="處理狀態", interactive=False) with gr.Column(scale=1, elem_classes="pm-panel meeting-panel"): minutes_output = gr.Textbox(label="整理完成後看這裡:摘要、重要事項、待辦與提醒", lines=14, interactive=False) transcript_output = gr.Textbox(label="轉錄文字 / 逐字稿", lines=10, interactive=False) with gr.Row(): with gr.Column(elem_classes="pm-panel"): meeting_history_site = gr.Dropdown(label="查看案場會議", choices=list_site_options()) refresh_meeting_history = gr.Button("更新會議清單") meeting_history_table = gr.Dataframe(value=meeting_rows(), label="會議紀錄清單", interactive=False, wrap=True) with gr.Tab("照片與文件"): gr.Markdown("### 照片與文件") gr.Markdown(GUIDE_TEXT["files"], elem_classes="pm-guide") with gr.Row(): with gr.Column(elem_classes="pm-panel"): photo_site = gr.Dropdown(label="照片案場", choices=list_site_options()) gr.Markdown("先上傳照片,再填備註,最後按下方藍色按鈕存入案場。", elem_classes="pm-guide") photo_input = gr.Image(type="filepath", label="1. 上傳現場照片", elem_classes="pm-upload") photo_notes = gr.Textbox(label="2. 照片備註", lines=3) photo_template_btn = gr.Button("帶入照片紀錄範本") photo_btn = gr.Button("3. 分析照片並存入案場", variant="primary") gr.ClearButton([photo_input, photo_notes], value="清空照片表單") photo_caption = gr.Textbox(label="照片描述", lines=3, interactive=False) photo_summary = gr.Textbox(label="現場摘要", lines=8, interactive=False) with gr.Column(elem_classes="pm-panel"): doc_site = gr.Dropdown(label="文件案場", choices=list_site_options()) gr.Markdown("先上傳文件照片,再填備註,最後按下方藍色按鈕辨識與摘要。", elem_classes="pm-guide") doc_image = gr.Image(type="filepath", label="1. 上傳文件照片", elem_classes="pm-upload") doc_notes = gr.Textbox(label="2. 文件備註", lines=3) doc_template_btn = gr.Button("帶入文件備註範本") ocr_btn = gr.Button("3. 辨識文件並產生摘要", variant="primary") gr.ClearButton([doc_image, doc_notes], value="清空文件表單") ocr_status = gr.Textbox(label="辨識狀態", interactive=False) ocr_text = gr.Textbox(label="辨識文字", lines=6, interactive=False) ocr_summary = gr.Textbox(label="文件摘要", lines=8, interactive=False) with gr.Tab("預算與附件"): gr.Markdown("### 預算與附件") gr.Markdown(GUIDE_TEXT["budget"], elem_classes="pm-guide") with gr.Row(): with gr.Column(scale=1, elem_classes="pm-panel"): budget_site = gr.Dropdown(label="預算案場", choices=list_site_options()) expense_title = gr.Textbox(label="支出項目", placeholder="例如:材料訂金、設計費、現場追加") expense_type = gr.Dropdown(label="支出類型", choices=EXPENSE_TYPES, value=EXPENSE_TYPES[0]) expense_amount = gr.Textbox(label="金額") expense_date = gr.DateTime(label="日期", include_time=False, type="string", timezone="Asia/Taipei", value=str(today_local())) expense_notes = gr.Textbox(label="支出備註", lines=3) expense_template_btn = gr.Button("帶入支出備註範本") add_expense_btn = gr.Button("新增支出並更新預算", variant="primary") gr.ClearButton([expense_title, expense_amount, expense_date, expense_notes], value="清空支出表單") expense_message = gr.Textbox(label="預算狀態", interactive=False) with gr.Column(scale=2, elem_classes="pm-panel"): budget_overview = gr.Markdown(budget_summary()) expense_table = gr.Dataframe(value=expense_rows(), label="支出明細", interactive=False, wrap=True) with gr.Row(): expense_selected_btn = gr.Button("此案場支出") expense_all_btn = gr.Button("全部支出") with gr.Row(): with gr.Column(scale=1, elem_classes="pm-panel"): attachment_site = gr.Dropdown(label="附件案場", choices=list_site_options()) attachment_file = gr.File(label="上傳附件", file_count="multiple") attachment_type = gr.Dropdown(label="附件類型", choices=["合約", "報價", "圖說", "照片", "會議", "其他"], value="其他") attachment_notes = gr.Textbox(label="附件備註", lines=3) add_attachment_btn = gr.Button("上傳附件並掛到案場", variant="primary") gr.ClearButton([attachment_file, attachment_notes], value="清空附件表單") attachment_message = gr.Textbox(label="附件狀態", interactive=False) with gr.Column(scale=2, elem_classes="pm-panel"): attachment_table = gr.Dataframe(value=attachment_rows(), label="附件清單", interactive=False, wrap=True) with gr.Row(): attachment_selected_btn = gr.Button("此案場附件") attachment_all_btn = gr.Button("全部附件") with gr.Tab("行事曆提醒"): gr.Markdown("### 行事曆提醒") gr.Markdown(GUIDE_TEXT["calendar"], elem_classes="pm-guide") with gr.Row(): with gr.Column(scale=1, elem_classes="pm-panel"): calendar_site = gr.Dropdown(label="案場", choices=list_site_options()) reminder_title = gr.Textbox(label="提醒標題", placeholder="例如:投標截止、現場會勘、文件送審") reminder_type = gr.Dropdown(label="提醒類型", choices=REMINDER_TYPES, value=REMINDER_TYPES[0]) reminder_time = gr.DateTime(label="日期時間", include_time=True, type="string", timezone="Asia/Taipei") reminder_duration = gr.Number(label="預估時間(分鐘)", value=60, precision=0, minimum=15, maximum=1440) reminder_before = gr.Dropdown( label="提前提醒", choices=[(f"{minutes} 分鐘", minutes) for minutes in REMINDER_MINUTES], value=60, ) reminder_owner = gr.Textbox(label="負責人") reminder_notes = gr.Textbox(label="備註", lines=3) add_reminder_btn = gr.Button("建立提醒並可匯到手機", variant="primary") gr.ClearButton([reminder_title, reminder_time, reminder_owner, reminder_notes], value="清空提醒表單") reminder_message = gr.Textbox(label="狀態", interactive=False) with gr.Column(scale=2, elem_classes="pm-panel"): reminder_table = gr.Dataframe(value=upcoming_reminders(30), label="未來 30 天提醒", interactive=False, wrap=True) with gr.Row(): upcoming_7_btn = gr.Button("7 天內") upcoming_30_btn = gr.Button("30 天內") selected_reminders_btn = gr.Button("此案場提醒") with gr.Row(): export_selected_calendar = gr.Button("下載此案場手機行事曆", variant="primary") export_all_calendar = gr.Button("下載全部手機行事曆", variant="primary") calendar_file = gr.File(label="手機/平板行事曆檔") with gr.Tab("歷史與搜尋"): gr.Markdown("### 歷史與搜尋") gr.Markdown(GUIDE_TEXT["history"], elem_classes="pm-guide") with gr.Row(): with gr.Column(elem_classes="pm-panel"): history_site = gr.Dropdown(label="案場", choices=list_site_options()) refresh_history = gr.Button("更新歷史") photo_history_table = gr.Dataframe(label="照片歷史", interactive=False, wrap=True) ocr_history_table = gr.Dataframe(label="文件歷史", interactive=False, wrap=True) with gr.Column(elem_classes="pm-panel"): gallery_site = gr.Dropdown(label="照片牆案場", choices=list_site_options()) refresh_gallery = gr.Button("更新照片牆") photo_gallery = gr.Gallery(label="照片牆") ocr_search = gr.Textbox(label="搜尋文件文字") search_button = gr.Button("搜尋") search_results = gr.Dataframe(label="搜尋結果", interactive=False, wrap=True) with gr.Tab("儀表板與報表"): gr.Markdown("### 儀表板與報表") gr.Markdown(GUIDE_TEXT["reports"], elem_classes="pm-guide") with gr.Row(): with gr.Column(elem_classes="pm-panel"): dashboard_site = gr.Dropdown(label="儀表板案場", choices=list_site_options()) dashboard_btn = gr.Button("更新儀表板", variant="primary") dashboard = gr.Markdown() with gr.Column(elem_classes="pm-panel"): detail_site = gr.Dropdown(label="詳細資料案場", choices=list_site_options()) detail_btn = gr.Button("查看詳細資料") detail = gr.Markdown() with gr.Row(): with gr.Column(elem_classes="pm-panel"): gantt_site = gr.Dropdown(label="甘特圖案場", choices=list_site_options()) gantt_btn = gr.Button("產生甘特圖") gantt_image = gr.Image(label="甘特圖", interactive=False) with gr.Column(elem_classes="pm-panel"): export_site = gr.Dropdown(label="報表案場", choices=list_site_options()) export_btn = gr.Button("下載 PDF / 試算表報表", variant="primary") pdf_report = gr.File(label="報表文件") excel_report = gr.File(label="試算表") refresh_btn = gr.Button("重新整理案場清單") login_button.click(verify_login, inputs=[login_user, login_pass, login_key], outputs=[login_message, login_area, main_area]) pm_note_template_btn.click(lambda: template_text("pm_note"), outputs=[workbench_notes]) risk_template_btn.click(lambda: template_text("risk"), outputs=[risk_mitigation]) meeting_template_btn.click(lambda: template_text("meeting"), outputs=[focus]) photo_template_btn.click(lambda: template_text("photo"), outputs=[photo_notes]) doc_template_btn.click(lambda: template_text("document"), outputs=[doc_notes]) expense_template_btn.click(lambda: template_text("expense"), outputs=[expense_notes]) location.change(location_map_preview, inputs=[location], outputs=[location_map]) quick_task_site_location.change(location_map_preview, inputs=[quick_task_site_location], outputs=[quick_task_map_preview]) meeting_btn.click( add_meeting, inputs=[meeting_site, meeting_title, participants, audio_input, focus, manual_transcript], outputs=[meeting_status, transcript_output, minutes_output, meeting_history_table, dashboard_home, case_table, reminder_table, task_table, task_picker, risk_table, risk_picker], ) refresh_meeting_history.click(meeting_rows, inputs=[meeting_history_site], outputs=[meeting_history_table]) photo_btn.click( scan_photo, inputs=[photo_site, photo_input, photo_notes], outputs=[photo_caption, photo_summary, photo_history_table, photo_gallery, dashboard_home, case_table], ) ocr_btn.click( scan_document, inputs=[doc_site, doc_image, doc_notes], outputs=[ocr_status, ocr_text, ocr_summary, ocr_history_table, dashboard_home, case_table], ) add_reminder_btn.click( add_reminder, inputs=[calendar_site, reminder_title, reminder_type, reminder_time, reminder_duration, reminder_before, reminder_owner, reminder_notes], outputs=[reminder_table, reminder_message, dashboard_home, case_table], ) load_workbench_btn.click( load_case_for_workbench, inputs=[workbench_site], outputs=[workbench_stage, workbench_category, workbench_budget, workbench_notes, workbench_summary], ) save_workbench_btn.click( update_case_from_workbench, inputs=[workbench_site, workbench_stage, workbench_category, workbench_budget, workbench_notes], outputs=[case_table, dashboard_home, workbench_summary, workbench_message], ) add_task_btn.click( add_task, inputs=[task_site, task_title, task_owner, task_agreed, task_due, task_priority, task_status, task_sync_calendar, task_notes], outputs=[task_table, dashboard_home, case_table, reminder_table, task_picker, task_message], ) task_selected_btn.click(lambda site: (task_rows(site), gr.update(choices=task_options(site), value=None)), inputs=[task_site], outputs=[task_table, task_picker]) task_all_btn.click(lambda: (task_rows(only_open=True), gr.update(choices=task_options(), value=None)), outputs=[task_table, task_picker]) complete_task_btn.click( complete_task, inputs=[task_site, task_picker], outputs=[task_table, dashboard_home, case_table, task_picker, task_message], ) add_risk_btn.click( add_risk, inputs=[risk_site, risk_title, risk_level, risk_status, risk_owner, risk_mitigation], outputs=[risk_table, dashboard_home, case_table, risk_picker, risk_message], ) risk_selected_btn.click(lambda site: (risk_rows(site), gr.update(choices=risk_options(site), value=None)), inputs=[risk_site], outputs=[risk_table, risk_picker]) risk_all_btn.click(lambda: (risk_rows(), gr.update(choices=risk_options(), value=None)), outputs=[risk_table, risk_picker]) resolve_risk_btn.click( resolve_risk, inputs=[risk_site, risk_picker], outputs=[risk_table, dashboard_home, case_table, risk_picker, risk_message], ) add_expense_btn.click( add_expense, inputs=[budget_site, expense_title, expense_type, expense_amount, expense_date, expense_notes], outputs=[expense_table, budget_overview, dashboard_home, case_table, expense_message], ) expense_selected_btn.click(lambda site: (expense_rows(site), budget_summary(site)), inputs=[budget_site], outputs=[expense_table, budget_overview]) expense_all_btn.click(lambda: (expense_rows(), budget_summary()), outputs=[expense_table, budget_overview]) add_attachment_btn.click( add_attachment, inputs=[attachment_site, attachment_file, attachment_type, attachment_notes], outputs=[attachment_table, dashboard_home, case_table, attachment_message], ) attachment_selected_btn.click(attachment_rows, inputs=[attachment_site], outputs=[attachment_table]) attachment_all_btn.click(attachment_rows, outputs=[attachment_table]) upcoming_7_btn.click(lambda: upcoming_reminders(7), outputs=[reminder_table]) upcoming_30_btn.click(lambda: upcoming_reminders(30), outputs=[reminder_table]) selected_reminders_btn.click(reminder_rows, inputs=[calendar_site], outputs=[reminder_table]) export_selected_calendar.click(export_calendar_ics, inputs=[calendar_site], outputs=[calendar_file]) export_all_calendar.click(lambda: export_calendar_ics(), outputs=[calendar_file]) refresh_history.click(lambda site: (photo_history(site), ocr_history(site)), inputs=[history_site], outputs=[photo_history_table, ocr_history_table]) refresh_gallery.click(gallery, inputs=[gallery_site], outputs=[photo_gallery]) search_button.click(search_ocr, inputs=[ocr_search], outputs=[search_results]) dashboard_btn.click(get_dashboard, inputs=[dashboard_site], outputs=[dashboard]) detail_btn.click(get_case_detail, inputs=[detail_site], outputs=[detail]) gantt_btn.click(generate_gantt, inputs=[gantt_site], outputs=[gantt_image]) export_btn.click(export_report, inputs=[export_site], outputs=[pdf_report, excel_report]) category_filter.change(filter_cases, inputs=[category_filter], outputs=[case_table, overview]) export_data_btn.click(export_data_backup, outputs=[data_backup_file]) dropdown_outputs = [ workbench_site, meeting_site, meeting_history_site, dashboard_site, photo_site, doc_site, history_site, gallery_site, export_site, detail_site, gantt_site, calendar_site, task_site, risk_site, budget_site, attachment_site, category, category_filter, ] add_site_btn.click( add_case, inputs=[site_name, location, client, stage, category, new_category, tags, bid_amount, manager_notes], outputs=[case_table, *dropdown_outputs, overview, site_message], ) quick_task_site_btn.click( quick_add_case_for_task, inputs=[quick_task_site_name, quick_task_site_location], outputs=[case_table, *dropdown_outputs, overview, task_message], ) restore_data_btn.click( restore_data_backup, inputs=[restore_data_file], outputs=[case_table, *dropdown_outputs, overview, site_message, storage_status], ) refresh_btn.click(lambda: refresh_all("案場清單已重新整理。"), outputs=[case_table, *dropdown_outputs, overview, site_message]) auto_refresh = gr.Timer(10) auto_refresh.tick( lambda: ( build_hero_html(), case_rows(), category_overview(), upcoming_reminders(30), task_rows(only_open=True), gr.update(choices=task_options(), value=None), risk_rows(), gr.update(choices=risk_options(), value=None), expense_rows(), budget_summary(), attachment_rows(), meeting_rows(), data_storage_status(), ), outputs=[dashboard_home, case_table, overview, reminder_table, task_table, task_picker, risk_table, risk_picker, expense_table, budget_overview, attachment_table, meeting_history_table, storage_status], show_progress="hidden", ) if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860, css=CSS, pwa=True, favicon_path="neil_log_icon.svg", head=PWA_HEAD)