| 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<date>\d{4}-\d{1,2}-\d{1,2})\s*(?P<period>上午|早上|中午|下午|晚上)?\s*(?P<hour>\d{1,2})(?:點|時|:)(?P<minute>\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""" |
| <div class="action-card {tone}"> |
| <div class="card-arrow">→</div> |
| <strong>{title}</strong> |
| <span>{value}</span> |
| <p>{desc}</p> |
| </div> |
| """ |
| 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""" |
| <section class="command-dashboard"> |
| <header class="command-header"> |
| <div> |
| <span>Neil您好</span> |
| <strong>{format_now_zh()}</strong> |
| </div> |
| <div class="command-status"><i></i>{health_text}</div> |
| </header> |
| <div class="health-line">{health_detail}</div> |
| <div class="command-summary"> |
| <div class="summary-copy"> |
| <b>今日進度報告與彙整</b> |
| <p>{report["summary"]}</p> |
| </div> |
| <div class="summary-number"> |
| <span>今日關注</span> |
| <strong>{due_count + task_due_count}</strong> |
| </div> |
| </div> |
| <div class="command-kpis"> |
| <div><span>案場</span><strong>{case_count}</strong></div> |
| <div><span>今日提醒</span><strong>{due_count}</strong></div> |
| <div><span>今日待辦</span><strong>{task_due_count}</strong></div> |
| <div><span>逾期追蹤</span><strong>{overdue_count + len(report['task_overdue'])}</strong></div> |
| <div><span>未完成待辦</span><strong>{report["open_tasks"]}</strong></div> |
| <div><span>高風險</span><strong>{report["high_risks"]}</strong></div> |
| <div><span>資料完成</span><strong>{completion}%</strong></div> |
| <div><span>預算使用</span><strong>{budget_ratio}%</strong></div> |
| </div> |
| </section> |
| """ |
| 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""" |
| <meta name="application-name" content="{APP_SHORT_NAME}"> |
| <meta name="apple-mobile-web-app-title" content="{APP_SHORT_NAME}"> |
| <meta name="apple-mobile-web-app-capable" content="yes"> |
| <meta name="apple-mobile-web-app-status-bar-style" content="default"> |
| <meta name="mobile-web-app-capable" content="yes"> |
| <meta name="theme-color" content="#25a9e8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> |
| """ |
|
|
|
|
| with gr.Blocks(title=APP_TITLE) as demo: |
| gr.HTML(f"<style>{CSS}</style>") |
| 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) |
|
|