from pathlib import Path from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union import gradio as gr from dotenv import load_dotenv load_dotenv() class Document_structureUI: # サンプルデータ (議案一覧) sample_data: List[Dict[str, str]] = [ { "議案番号": "1", "タイトル": "役員様より各修繕提案の件", "meeting_no": 4, "物件名": "経堂パーク・マンション", "文書種別": "議案書", "年度": "2021", "会議日": "2022-05-18", "議案タイプ": "審議事項", "議案内容": "前回理事会に引き続きご検討お願いいたします。\n<添付資料>\n·株式会社ホゼン 見積書\n·ルシア株式会社 提案書\n【参考:前回の審議結果】\n<審議経過>\n1非常階段の長尺シートの全面貼り替えの件\n→専門業者から見積りと仕様書の提示を受け検討しました。本件について、他メーカーのカタログを取り寄せ、詳細検討を進めることとしました。\n2正面玄関階段の手すり設置工事の件\n→専門業者からイメージ図を取得し検討をしました。本件について、シンプルな仕様で詳細検討を進めることとし、同社へ次回理事会にてプレゼンテーションを依頼することとしました。", "AI提案": "設備修繕", "status": "理事会議論中", "memorandum": "非常階段の長尺シート貼り替えと正面玄関の手すり設置工事について、業者選定と見積もり取得を進め、来期の修繕計画および予算案に計上する必要がある。", }, { "議案番号": "2", "タイトル": "マンション隣地(東側) ブロック塀及び垣根復旧対応報告", "meeting_no": 4, "物件名": "経堂パーク・マンション", "文書種別": "議案書", "年度": "2021", "会議日": "2022-05-18", "議案タイプ": "報告事項'", "議案内容": "マンション隣地(東側) ブロック塀および垣根復旧作業が完了しました。 (2022年5月11日完了)\n管理会社にブロック塀に関して当初のコーキング案と今回のステンレスアングル案の実質的な違いに関する質問をし、結論として最終的にはステンレスアングル案の方が当管理組合にとって有利であったという管理会社としてのご見解をいただきました。\n<審議経過>\n大東建託より昨年11月に是正工事の説明を受け、年明けに設計図と完成イメージ図の提出を受け確認しました。 本件について、敷地境界線のコーキングが経年劣化した場合、補修の費用負担は隣地(長島 邸) であると大東建託担当者から説明があったことから、後のことを考えその旨の書面作成を条件に工事許可をすることとしましたが、後日、大東建託担当より書面取り付けが大変厳しい状況である旨報告を受けました。\nその後、代替え案として、劣化の少ないステンレスのアングルを設置し、隙間を埋めて植栽を復旧する工法の提案を受けました。\n本図面を管理会社技術部にて確認した結果、隙間を埋めるのはあくまで美観状の問題のため、耐候性のあるステンレス部材であれば問題ない旨確認しました。 ただし、今後の劣化を考え、アングルを留めるボルトも耐候性、耐食性等のあるステンレス材を使用した方が良い旨、先方に申し送りしアングル留付用ボルトもステンレス製固定となりました。以上から本仕様にて工事承認としました。", "AI提案": "設備修繕", "status": "終了済み", "memorandum": "なし", }, { "議案番号": "3", "タイトル": "管理計画認定制度およびマンション管理適正評価制度への登録の件", "meeting_no": 4, "物件名": "経堂パーク・マンション", "文書種別": "議案書", "年度": "2021", "会議日": "2022-05-18", "議案タイプ": "審議事項", "議案内容": "標題の件、2022年4月より、マンションにおける建物維持管理の新たな指針として、マンション管理適正化法に基づく「管理計画認定制度」および一般社団法人マンション管理業協会による「マンション管理適正評価制度」の二つの制度が運用開始となります。\n「管理計画認定制度」は、マンションの管理計画が一定の基準を満たす場合に、適切な管理計画を持つマンションとして認定を受けることができる制度であり、認定を受けることができたマンションは市場において評価されることが期待されます。\nまた、「マンション管理適正評価制度」は、同様にマンションの管理状況をチェックし、その情報がポータルサイト等で開示されることで、管理の行き届いているマンションの管理評価が市場価値·流通価格に反映されることが期待されます。一方、登録しない=非開示となっているマンションは市場での評価が困難となることも想定されます。\n本件に関して、管理会社にて両制度の事前評価を実施しましたので、以下の結果をご確認いただくとともに、本結果をご参考のうえ、両制度への登録および情報開示に関して今年度通常総会へ議案上程することについて、ご審議をお願いいたします。\nまた、総会への議案上程に際して、より評価を向上させるための対応策もご提案させていただきますので合わせてご審議ください。\nなお、今後のインセンティブ制度等確立のため、今回ご提出した「マンション管理適正評価制度」の『等級評価結果』および、配管の材質や漏水事故の発生率等の情報をまとめた『維持管理情報』の提供をマンション管理業協会より求められる可能性があります。\nその場合、両制度への登録の有無に関わらず、マンション名付でマンション管理業協会へ提出することについて、ご承諾をお願いいたします。\n※情報提供は、将来の損害保険インセンティブ制度確立のための“参考資料”として取り扱いされますが、個々の提供情報について審査されるものではありません。また、管理会社とマンション管理業協会にて、損害保険インセンティブ制度の検討以外に使用されないこと、秘密情報の取り扱い等を規定した秘密保持契約を締結しております。\n<事前評価結果のご報告>\n事前の評価結果は以下のとおりです。\n管理計画認定制度:不適合\nマンション管理適正評価制度:★4つ (76ポイント)\n※評価結果の詳細は添付の「等級評価シート」をご参照ください。\n<添付資料>\n·管理計画認定制度·マンション管理適正評価制度~登録のご提案~\n·等級評価シート", "AI提案": "管理計画認定制度", "status": "総会での報告承認待ち", "memorandum": "両制度への登録費用、および評価向上策にかかる費用を来期予算に計上することを検討する。総会での承認が必要。", }, ] THEME_KEYS: ClassVar[list[str]]=[ "照明設備", "エレベーター", "エントランス自動ドア", "通信・IT設備", "受水槽", "宅配ボックス", "固定電話・IP網", "消火器", "防犯カメラ", "大規模修繕", "建物調査診断", "設備修繕", "植栽", "機械式駐車場", "自転車置場", "理事会・総会", "管理委託契約", "管理組合運営", "管理規約・使用細則", "管理計画認定制度", "リフォーム工事細則", "年度予算・検討課題", "未収金", "長期修繕計画", "ごみ", "防災", "管理会社対応", "電子化・DX", "会計・収支", "その他", ] # 物件名の候補リスト BUKKEN_CHOICES: ClassVar[list[str]] = [ "経堂パーク・マンション", "祖師ヶ谷大蔵パーク・ホームズ", "パークシティ弦巻", "パークホームズ学芸大学グレーススクエア", "パーク・ノヴァ猿江恩賜公園", ] def __init__(self) -> None: """初期化""" self.approval_status: Dict[int, bool] = dict.fromkeys(range(len(self.sample_data)), False) def _get_custom_css(self) -> str: """カスタムCSSを取得""" return """ .status-approved { color: #059669; background-color: #d1fae5; padding: 4px 12px; border-radius: 9999px; font-size: 0.875rem; font-weight: 600; display: inline-block; } .status-pending { color: #d97706; background-color: #fef3c7; padding: 4px 12px; border-radius: 9999px; font-size: 0.875rem; font-weight: 600; display: inline-block; } .status-saved { color: #059669; font-size: 0.875rem; font-weight: 600; } /* highlight panels */ .theme-highlight { background: #eff6ff; /* blue-50 */ border-left: 4px solid #60a5fa; /* blue-400 */ padding: 12px; border-radius: 6px; } .panel-gray { background: #eff6ff; /* blue-50 */ border-left: 4px solid #60a5fa; /* blue-400 */ padding: 12px; border-radius: 6px; } .panel-white { background: #ffffff; border-left: 4px solid #60a5fa; /* blue-400 */ padding: 12px; border-radius: 6px; } .small-hint { font-size: 12px; color: #1e40af; /* blue-800 */ } /* blue buttons */ .btn-blue { background: linear-gradient(135deg, #4F7FFF 0%, #1D4ED8 100%) !important; color: #ffffff !important; border: none !important; box-shadow: 0 2px 8px rgba(29, 78, 216, 0.25) !important; } .btn-blue:hover { filter: brightness(1.05); transform: translateY(-1px); } /* tabs - active/hover color (force underline to blue) */ button[role="tab"] { position: relative !important; border-bottom: 2px solid transparent !important; box-shadow: inset 0 -2px transparent !important; } button[role="tab"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: transparent; } button[role="tab"][aria-selected="true"] { background: #eff6ff !important; /* blue-50 */ color: #1d4ed8 !important; /* blue-700 */ } button[role="tab"][aria-selected="true"]::after { background: #1d4ed8 !important; } button[role="tab"]:hover { color: #1d4ed8 !important; } button[role="tab"]:hover::after { background: #60a5fa !important; /* lighter on hover */ } /* accordion labels as headings */ details > summary { font-weight: 700 !important; font-size: 1.05rem !important; color: #111827 !important; /* gray-900 */ } /* approval message textbox */ .msg-approved textarea { background-color: #d1fae5 !important; /* green-100 */ color: #059669 !important; /* green-600 */ border: 1px solid #059669 !important; border-left: 4px solid #059669 !important; } .bg-white { background: #fff !important; border-radius: 6px; padding: 12px; } .btn-water { background-color: #5bc0eb !important; color: #ffffff !important; border-color: #5bc0eb !important; } .btn-water:hover, .btn-water:focus { background-color: #48b3de !important; border-color: #48b3de !important; } """ def process_file(self, file: Optional[Union[str, Path]]) -> Tuple[Any, Any]: """ファイルアップロード処理""" if file is None: return gr.update(value="⚠️ ファイルが選択されていません", visible=True), gr.update(), gr.update() file_name = Path(file).name if isinstance(file, (str, Path)) else "uploaded_file" return ( gr.update(value=f"{file_name}がアップロードされました", visible=True), gr.update(visible=True), gr.update(value="アップロード済み", variant="secondary"), ) def switch_tab(self,selected:int) -> Any: return gr.update(selected=selected) def edit_info(self) -> Any: return ( gr.update(visible=False), gr.update(visible=True), gr.update(visible=False), gr.update(interactive=True), gr.update(interactive=True), gr.update(interactive=True), gr.update(interactive=True), ) def save_info(self) -> Any: return ( gr.update(visible=True), gr.update(visible=False), gr.update(visible=True), gr.update(interactive=False), gr.update(interactive=False), gr.update(interactive=False), gr.update(interactive=False), ) def approve_item(self, index: int) -> str: """個別の議案を承認""" self.approval_status[index] = True return f"議案 {self.sample_data[index]['議案番号']}: {self.sample_data[index]['タイトル']} を承認しました。" def unapprove_item(self, index: int) -> str: """個別の議案を承認""" self.approval_status[index] = False return f"議案 {self.sample_data[index]['議案番号']}: {self.sample_data[index]['タイトル']} を承認しました。" def _get_approval_progress_html(self) -> str: """承認進捗のHTML (バッジ) を返す""" approved_count = sum(1 for v in self.approval_status.values() if v is True) total = len(self.sample_data) clazz = "status-approved" if approved_count == total else "status-pending" return "" + f"{approved_count}/{total} 承認済み" + "" def on_approve_and_close(self, idx: int) -> Tuple[str, Any, Any, Any]: if self.approval_status.get(idx) is False: """承認メッセージ、メッセージ更新、進捗更新、必要ならタブ遷移を返す""" msg = self.approve_item(idx) progress_html = self._get_approval_progress_html() all_done = sum(1 for v in self.approval_status.values() if v is True) == len(self.sample_data) register_btn_update = gr.update(visible=all_done) btn_update = gr.update(value="✅ 承認済み", variant="secondary", interactive=True) if all_done: approve_all_btn_update = gr.update(value="✅すべて承認済み", variant="secondary") else: approve_all_btn_update = gr.update(value="すべて承認", variant="primary") return ( msg, gr.update(value=progress_html), gr.update(open=False), btn_update, register_btn_update, approve_all_btn_update, ) msg = self.unapprove_item(idx) progress_html = self._get_approval_progress_html() all_done = sum(1 for v in self.approval_status.values() if v is True) == len(self.sample_data) register_btn_update = gr.update(visible=all_done) btn_update = gr.update(value="承認", variant="primary", interactive=True) if all_done: approve_all_btn_update = gr.update(value="✅すべて承認済み", variant="secondary") else: approve_all_btn_update = gr.update(value="すべて承認", variant="primary") return ( msg, gr.update(value=progress_html), gr.update(open=True), btn_update, register_btn_update, approve_all_btn_update, ) def approve_all_and_go_back(self) -> Any: all_done = sum(1 for v in self.approval_status.values() if v is True) == len(self.sample_data) if all_done: for i in range(len(self.sample_data)): self.approval_status[i] = False progress_html = self._get_approval_progress_html() register_btn_update = gr.update(visible=False) per_button_updates = [ gr.update(value="承認", variant="primary", interactive=True) for _ in range(len(self.sample_data)) ] acc_updates = [gr.update(open=True) for _ in range(len(self.sample_data))] return ( gr.update(value=progress_html), register_btn_update, gr.update(value="すべて承認", variant="primary"), *per_button_updates, *acc_updates, ) """すべ議案を承認してStep1に戻る。メッセージは表示しない。""" for i in range(len(self.sample_data)): self.approval_status[i] = True progress_html = self._get_approval_progress_html() register_btn_update = gr.update(visible=True) per_button_updates = [ gr.update(value="✅ 承認済み", variant="secondary", interactive=True) for _ in range(len(self.sample_data)) ] acc_updates = [gr.update(open=False) for _ in range(len(self.sample_data))] return ( gr.update(value=progress_html), register_btn_update, gr.update(value="✅すべて承認済み", variant="secondary"), *per_button_updates, *acc_updates, ) def on_register_documents(self): # TODO: 実際の資料登録処理をここに実装する # 例: DB保存やAPI呼び出しなど # 成功メッセージを表示 return gr.update(visible=True),gr.update(visible=True) def _search_bukken(self, search_text: str) -> tuple[gr.Radio, str, str]: """物件名を検索して候補を更新""" print(f"DEBUG: _search_bukken called with: '{search_text}'") print(f"DEBUG: Available choices: {self.BUKKEN_CHOICES}") if not search_text or len(search_text.strip()) == 0: # 検索テキストが空の場合は候補を非表示 print("DEBUG: Empty search text, returning empty choices") return gr.Radio(choices=[], visible=False, value=None), "", "" # 検索テキストで候補を絞り込み(大文字小文字を区別しない) search_text_lower = search_text.lower() filtered_choices = [choice for choice in self.BUKKEN_CHOICES if search_text_lower in choice.lower()] print(f"DEBUG: Filtered choices: {filtered_choices}") if len(filtered_choices) == 0: # マッチする候補がない場合 print("DEBUG: No matches found") return gr.Radio(choices=[], visible=False, value=None), "", "" # 候補が1件の場合は自動的に選択して値を設定 if len(filtered_choices) == 1: selected = filtered_choices[0] keiyaku_name = f"{selected}管理組合" print(f"DEBUG: Single match found: {selected}") return ( gr.Radio(choices=filtered_choices, value=selected, visible=True), selected, keiyaku_name, ) # 複数候補がある場合は表示 print(f"DEBUG: Multiple matches found: {filtered_choices}") return gr.Radio(choices=filtered_choices, value=None, visible=True), "", "" def _select_bukken(self, selected: str) -> tuple[str, str, str]: """物件を選択""" print(f"DEBUG: _select_bukken called with: '{selected}'") if not selected: print("DEBUG: No selection, returning empty values") return "", "", "" # 選択された値を各フィールドに反映 keiyaku_name = f"{selected}管理組合" print(f"DEBUG: Setting selected_bukken='{selected}', keiyaku_name='{keiyaku_name}'") return selected, selected def create_interface(self): """document_structure_uiの内容を生成する関数""" components = {} with gr.Column(): gr.Markdown("# 資料登録システム") with gr.Tabs() as tabs: components["tabs"] = tabs with gr.Tab("Step1: ファイルアップロード", id=0): gr.Markdown("## 基本情報登録") with gr.Row(): with gr.Column(scale=0.3, min_width=1): gr.Markdown("") with gr.Column(scale=4, min_width=15, variant="panel"): gr.Markdown("**物件名**") with gr.Column(scale=16, min_width=300): with gr.Column(): self.bukken_search = gr.Textbox( placeholder="🔍物件名を入力してください (例: 経堂、パーク、など) ", show_label=False, interactive=True, container=False, ) self.bukken_select = gr.Radio( choices=[], show_label=False, interactive=True, visible=False, ) # 選択された物件名を保持する隠しフィールド self.selected_bukken = gr.Textbox(value="", visible=False) # Step 1: 物件検索機能 self.bukken_search.change( fn=self._search_bukken, inputs=[self.bukken_search], outputs=[self.bukken_select, self.selected_bukken], ) # Step 1: 物件選択時の処理 self.bukken_select.change( fn=self._select_bukken, inputs=[self.bukken_select], outputs=[self.selected_bukken, self.bukken_search], ) with gr.Row(): with gr.Column(scale=0.3, min_width=1): gr.Markdown("") with gr.Column(scale=4, min_width=15, variant="panel"): gr.Markdown("**資料タイプ**") with gr.Column(scale=16, min_width=300): self.meeting_type = gr.Dropdown( choices=["理事会", "総会"], value="理事会", show_label=False, interactive=True, container=False, ) gr.Markdown("## ファイルアップロード") with gr.Row(): with gr.Column(scale=2): file_input = gr.File( label="ファイルを選択(PDF, DOCX)", file_types=[".pdf", ".docx"], file_count="single", type="filepath", ) components["file_input"] = file_input upload_btn = gr.Button("アップロード", variant="primary", size="lg", scale=1) structure_btn = gr.Button( "AIによるデータ整理", variant="primary", size="lg", scale=1, visible=False, elem_classes=["btn-blue"], ) components["upload_btn"] = upload_btn components["structure_btn"] = structure_btn upload_status = gr.Markdown(value="", visible=False) components["upload_status"] = upload_status with gr.Tab("Step2: AI出力の確認", id=1): gr.Markdown("# 共通データの確認") with gr.Row(): gr.Markdown("") with gr.Column(): save_info_message = gr.Textbox( value="共通データを修正しました。", label="", show_label=False, interactive=False, visible=False, elem_classes=["msg-approved"], container=False ) components["save_info_message"] = save_info_message with gr.Column(scale=3): edit_info_btn = gr.Button(value="共通データの修正", variant="primary") components["edit_info_btn"] = edit_info_btn save_info_btn = gr.Button( "修正内容の確定", variant="primary", # 見た目は必要に応じて visible=False, # 任意 ) components["save_info_btn"] = save_info_btn with gr.Row(): with gr.Column(scale=3.6, min_width=10, variant="panel"): gr.Markdown("**開催時期**") with gr.Column(scale=0.2, min_width=1): gr.Markdown("第") with gr.Column(scale=3, min_width=120): meeting_no = gr.Textbox( value=self.sample_data[0]["meeting_no"], placeholder="例) 3", show_label=False, interactive=False, container=False, ) # 期数をMarkdownで表示 with gr.Column(scale=0.2, min_width=1): gr.Markdown("回") with gr.Column(scale=0.3, min_width=1): gr.Markdown("") with gr.Column(scale=3, min_width=120): year = gr.Textbox( value=self.sample_data[0]["年度"], placeholder="例) 2023", show_label=False, interactive=False, container=False, ) # 期数をMarkdownで表示 with gr.Column(scale=0.5, min_width=3): gr.Markdown("年度") with gr.Column(scale=0.3, min_width=1): gr.Markdown("") with gr.Column(scale=0.9, min_width=1): gr.Markdown("開催日時") with gr.Column(scale=4, min_width=120): meeting_date = gr.DateTime( value=self.sample_data[0]["会議日"], include_time=False, # 時間ピッカーを有効 type="string", # 文字列として返す(扱いやすい) interactive=False, show_label=False, ) with gr.Row(): with gr.Column(scale=3, min_width=10, variant="panel"): gr.Markdown("**文書種別**") with gr.Column(scale=6, min_width=150): doc_type = gr.Dropdown( choices=["議案書", "議事録"], value=self.sample_data[0]["文書種別"], show_label=False, interactive=False, container=False, ) with gr.Column(scale=6, min_width=1): gr.Markdown("") gr.Markdown("") gr.Markdown("") gr.Markdown("# 各議案データの確認") with gr.Row(): global_message = gr.Textbox(label="", show_label=False, interactive=False, visible=False) components["global_message"] = global_message approval_progress = gr.HTML(value=self._get_approval_progress_html()) components["approval_progress"] = approval_progress with gr.Column(): approve_all_btn = gr.Button(value="すべて承認", variant="primary", size="lg") components["approve_all_btn"] = approve_all_btn register_doc_btn = gr.Button( "このテーマキーで資料登録", variant="primary", # 見た目は必要に応じて size="lg", visible=False, # 任意 ) components["register_doc_btn"] = register_doc_btn register_doc_message = gr.Textbox( value="資料登録完了しました", label="", show_label=False, interactive=False, visible=False, elem_classes=["msg-approved"], container=False ) with gr.Row(): gr.Markdown("") return_step1_btn = gr.Button( "ファイルアップロード画面に戻る", variant="secondary", visible=False, ) agenda_components = [] approve_btn_list = [] acc_list = [] gr.Markdown("") for i, data in enumerate(self.sample_data): with gr.Blocks(): with gr.Row(elem_classes=["agenda-header-row"]): with gr.Column(scale=9): gr.Markdown( f"### 議案 {data['議案番号']}: {data['タイトル']}", elem_classes=["agenda-title"], ) with gr.Column(scale=3): approve_btn = gr.Button("承認", variant="primary") approve_btn.elem_classes=["btn-water"] acc = gr.Accordion(label="", open=True, elem_classes=["agenda-accordion-body"]) with acc: with gr.Row(): with gr.Column(scale=2): gr.Dropdown( choices=["審議事項", "報告事項", "その他"], value=data["議案タイプ"], label="議案タイプ", interactive=True, # 編集可能に ) gr.Textbox( value=data["議案内容"], label="議案内容", show_label=True, lines=2, interactive=False, ) with gr.Column(scale=1): gr.Dropdown( label="ステータス", choices=["理事会議論中","総会での報告承認待ち","終了済み"], value=data["status"], interactive=True, ) gr.Textbox( value=data["memorandum"], label="備忘", show_label=True, interactive=True, ) with gr.Group(elem_classes=["panel-gray"]): gr.Markdown("### 議案のテーマ付与") gr.Dropdown( label="テーマキー", choices=self.THEME_KEYS, value=data["AI提案"], interactive=True, ) message = gr.Textbox( label="", show_label=False, interactive=False, visible=False, elem_classes=["msg-approved"], ) approve_btn.click( fn=lambda idx=i: self.on_approve_and_close(idx), outputs=[ message, approval_progress, acc, approve_btn, register_doc_btn, approve_all_btn, ], ) agenda_components.append({"approve_btn": approve_btn, "message": message}) approve_btn_list.append(approve_btn) # ← 追加 acc_list.append(acc) gr.Markdown("") gr.Markdown("") components["upload_btn"].click( fn=self.process_file, inputs=[components["file_input"]], outputs=[components["upload_status"], components["structure_btn"], components["upload_btn"]], ) components["structure_btn"].click( fn=lambda idx=1: self.switch_tab(idx), outputs=[components["tabs"]], ) components["edit_info_btn"].click( fn=self.edit_info, outputs=[ components["edit_info_btn"], components["save_info_btn"], components["save_info_message"], meeting_no, year, meeting_date, doc_type, ], ) components["save_info_btn"].click( fn=self.save_info, outputs=[components["edit_info_btn"], components["save_info_btn"], components["save_info_message"], meeting_no, year, meeting_date, doc_type, ], ) components["approve_all_btn"].click( fn=self.approve_all_and_go_back, outputs=[ approval_progress, components["register_doc_btn"], components["approve_all_btn"], *approve_btn_list, *acc_list, ], ) components["register_doc_btn"].click( fn=self.on_register_documents, outputs=[register_doc_message,return_step1_btn], ) return_step1_btn.click(fn=lambda idx=0: self.switch_tab(idx),outputs=[components["tabs"]]) return components # アプリケーション起動 def main(): """メイン関数""" app = Document_structureUI() demo = app.create_interface() demo.launch(server_name="0.0.0.0", server_port=7860, share=False) if __name__ == "__main__": main()