顧客管理ソフトの作成 – 第2回

今回はレイアウトの作成をする。

はじめに

まずはイメージしやすいようにレイアウトを作成する。

今回やること

今回やることは以下通りです。

  • 開発環境の準備
  • サイドメニューの作成
  • 顧客一覧の作成
  • 顧客詳細の作成

開発環境の準備

開発環境

  • windows11 home
  • Python 3.13.9
  • flet 0.28.3

フォルダ構成

フォルダ構成は、fletのdocumentationを参考にする。

└── crm

    ├── src

    │   ├── assets

    │   │   └── icon.png

    │   └── main.py

    ├── storage

    ├── pyproject.toml

    └── README.md

pythonの仮想環境の作成

作成したフォルダに移動して下記のコマンドを実行。

コマンドに実行には visual studio code のterminal を使用している。

terminal

py -3.13 -m venv .venv

python -m venv .venvとすると、古いpythonがインストールされるのでバージョンを明示して実行している。

terminal

.venv\Scripts\activate

成功すると、terminalの先頭に(venv)と表示される。

fletのインストール

バージョン指定でインストールしたいので下記のコマンド実行。

terminal

python -m pip install flet==0.28.3

Hello, world!

documentationのHello,world!を実行。インストールしたfeltのバージョン0.28.3では、ft.runではなくft.appが正しいようだ。terminalでmain.pyのあるフォルダに移動して、実行結果が下記の画像のように「Hello, world!」と表示されれば成功。

main.py

import flet as ft


def main(page: ft.Page):
    page.add(ft.Text(value="Hello, world!"))


ft.app(main)

terminal

python main.py

レイアウトの作成

レイアウトの作成には下記のコントロールを組み合わせて作成していく。

  • ft.Row() … 横に並べる
  • ft.Column() … 縦に並べる
  • ft.Container() … Rowなどのコントロールをまとめるなど

サイドメニュー

サイドメニューは管理しやすいように、sidebar_menu.pyにコンポーネントとして作成する。

sidebar_menu.py

"""サイドバーメニューコンポーネント"""

import flet as ft


class SidebarMenu(ft.Container):
    """サイドメニュー"""

    def __init__(self, page, initial_menu):
        super().__init__()
        self.page = page
        # 現在選択中のメニュー
        self.current_menu = initial_menu

        self.width = 200
        self.padding = 15

        # メニュー項目を作成
        self.content = self._build_menu()

    def _build_menu(self):
        """メニュー全体を作成(タイトル、区切り線、メニュー項目)"""
        return ft.Column(
            controls=[
                ft.Text(
                    "顧客管理ソフト",
                    size=16,
                    weight=ft.FontWeight.BOLD,
                ),
                # 区切り線
                ft.Divider(color=ft.Colors.BLACK87, height=30),
                # メニュー項目
                self._create_menu_item(
                    "顧客管理",
                    "clients",
                    ft.Icons.SUPERVISED_USER_CIRCLE_SHARP,
                ),
                self._create_menu_item("設定", "settings", ft.Icons.SETTINGS),
            ],
            spacing=10,
        )

    def _create_menu_item(self, text, menu_id, icon):
        """メニュー項目を作成"""
        # 選択したメニューを分かりやすいように色を変えるためのフラグ
        if self.current_menu == menu_id:
            is_selected = True
        else:
            is_selected = False

        return ft.Container(
            content=ft.Row(
                controls=[
                    ft.Icon(icon),
                    ft.Text(text, size=14),
                ],
                spacing=10,
            ),
            bgcolor=ft.Colors.GREY_300 if is_selected else ft.Colors.TRANSPARENT,
            padding=10,
            border_radius=5,
        )

main.pyを下記のように修正して動作確認。

main.py

"""顧客管理ソフト - メイン"""

import flet as ft
from sidebar_menu import SidebarMenu


def main(page: ft.Page):
    side_menu = SidebarMenu(page, initial_menu="clients")
    page.add(side_menu)


ft.app(main)

レイアウトクラスの作成

サイドメニューができたので、メイン画面を作成したいがその前にサイドメニューとメイン画面はセットで取り扱う方がよさそうさのでレイアウトクラスを作成する。

app_layout.py

"""アプリケーション全体のレイアウト"""

import flet as ft
from sidebar_menu import SidebarMenu


class AppLayout(ft.Row):
    """サイドバー付きアプリレイアウト"""

    def __init__(self, page, initial_menu):
        super().__init__()
        self.page = page

        # initial_menu=initial_menuというように同じ名称になっているのはpythonの慣習らしい。
        # 引数が少ないときは位置引数が慣習らしい。
        self.sidebar_menu = SidebarMenu(page, initial_menu=initial_menu)

        # メインコンテンツ(初期メニューに応じて表示)
        # ここにメインコンテンツをロードするコードを書く
        # initial_content = self._load_main_content(initial_menu)
        # self.main_content = ft.Container(
        #     content=initial_content,
        # )

        # レイアウトの構築
        self.controls = [
            self.sidebar_menu ,
            # self.main_content,
        ]

main.pyを下記のように修正して動作確認。

main.py

"""顧客管理ソフト - メイン"""

import flet as ft
from app_layout import AppLayout


def main(page: ft.Page):
    layout = AppLayout(page, initial_menu="clients")
    page.add(layout)


ft.app(main)

見た目は変わってないが、これでサイドメニューとメイン画面をセットで取り扱う準備ができた。

メイン画面の作成

一覧画面

顧客一覧を表示するレイアウトを作成する。

client_app.py

"""顧客管理アプリのメインクラス"""

import flet as ft


class ClientManagementApp(ft.Column):
    """得意先管理アプリ"""

    def __init__(self, page):
        super().__init__()
        self.page = page

        # 初期化処理
        self._create_header()
        self._create_layout()

    def _create_header(self):
        """ヘッダー部分を作成"""
        self.title = ft.Text(
            "顧客管理",
            size=24,
            weight=ft.FontWeight.BOLD,
        )

        self.bnt_new_client = ft.ElevatedButton(
            "新規得意先を追加",
            icon="add",
            on_click="hoge",
            style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=5)),
        )

        self.header = ft.Row(controls=[self.title, self.bnt_new_client])

    def _create_layout(self):
        self.controls = [
            self.header,
        ]

app_layout.pyを修正して動作確認。

app_layout.py

"""アプリケーション全体のレイアウト"""

import flet as ft
from sidebar_menu import SidebarMenu
from client_app import ClientManagementApp


class AppLayout(ft.Row):
    """サイドバー付きアプリレイアウト"""

    def __init__(self, page, initial_menu):
        super().__init__()
        self.page = page      

        # initial_menu=initial_menuというように同じ名称になっているのはpythonの慣習らしい。
        # 引数が少ないときは位置引数が慣習らしい。
        self.sidebar_menu = SidebarMenu(page, initial_menu=initial_menu)

        # メインコンテンツ(初期メニューに応じて表示)
        initial_content = self._load_main_content(initial_menu)
        self.main_content = ft.Container(
            content=initial_content,
        )

        # レイアウトの構築
        self.controls = [
            self.sidebar_menu,
            self.main_content,
        ]

    def _load_main_content(self, menu_id):
        """メニューIDに応じたメインコンテンツを読み込む"""
        if menu_id == "clients":
            return ClientManagementApp(self.page)

コントロール間の間隔などを調整する。その前に、コントロール間の区切りが見にくいので背景色を変える。

  • sidebar_menu.pyのinitに、self.bgcolor = ft.Colors.BLUE_100を追加
  • client_app.pyのinitに、self.bgcolor = ft.Colors.GREEN_100を追加

sidebar_menu.py

def __init__(self, page, initial_menu):
    …
        self.width = 200
        self.padding = 15
        self.bgcolor = ft.Colors.BLUE_100 # 追加

client_app.pyのClientManagementAppは、ft.Columnを継承している。ft.Columnは背景色を持たないためself.bgcolorの設定がない。そのためft.Containerを継承するように変更する。

さらにself.controlsの代わりに、self.contentにft.Columnを入れる形に変更。

client_app.py

class ClientManagementApp(ft.Container): # ft.Containerに変更

def __init__(self, page):
    …
    self.page = page
        self.bgcolor = ft.Colors.GREEN_100 # 追加

def _create_layout(self):
        self.content = ft.Column( # contentに変更して、その中にcolumnを入れる
            controls=[
                self.header,
            ]
        )

そうするとサイドメニューとメイン画面に背景色がついて区切りが分かりやすくなった。結果をみるとサイドメニュー、メイン画面ともに現在配置している項目分だけしか領域を使用していない。

サイドメニューは下方向に広げて、メイン画面は画面いっぱいに広がるように修正する。

まず、メイン画面のコントロールの始まりの位置がずれているので、app_layout.pyに下記のコードを追加する。

app_layout.py

def __init__(self, page, initial_menu):
        …
        self.page = page
        self.vertical_alignment = ft.CrossAxisAlignment.START # 追加

次に、app_layout.pyに下記のコードを追加する。そうするとサイドメニュー、メイン画面ともに縦方向に広がる。expandは設定した子要素が親要素の余白を埋めるように広がる。

ここでは、AppLayout(ft.Row)が親要素であるmain(page: ft.Page):のpageの余白を埋めている。

app_layout.py

def __init__(self, page, initial_menu):
        …
        self.page = page
        self.vertical_alignment = ft.CrossAxisAlignment.START
        self.expand = True # 追加

縦方向には広がったがメイン画面が横方向には広がっていない。AppLayout(ft.Row)にexpand=Trueを設定したので横方向には広がると思ったが、Rowの子要素はコンテンツの幅しか持たないという仕様のようで、AppLayout(ft.Row)の子要素であるself.main_content(ft.Container)にもexpand=Trueを設定すると横いっぱいに広がる。

app_layout.py

def __init__(self, page, initial_menu):
        …
        initial_content = self._load_main_content(initial_menu)
        self.main_content = ft.Container(
            content=initial_content,
            expand=True, # 追加
        )

このexpandの動作は、コントロールの主軸、交差軸が関係するよう。Rowは主軸が横方向、交差軸は縦方向。交差軸はデフォルトで親要素の余白いっぱいに広がり、主軸はその子要素のコントロールの幅しか広がらない。そのため、その子要素にもexpandを設定する必要があるよう。

aiのclaudeに質問したら上記のような回答だった。正直今の私には理解ができないので、とりあえず試行錯誤しながら進めていく。

メイン画面が少し詰まっているのでpaddingを設定する。

client_app.py

def __init__(self, page):
        …
        self.bgcolor = ft.Colors.GREEN_100
        self.padding = 15 # 追加

次に顧客一覧を表示するdatatableを作成する。client_app.pyにdef create_main_content(self)を追加。

client_app.py

def _create_main_content(self):
        """メイン部分を作成"""
        self.data_table = ft.DataTable(
            columns=[
                ft.DataColumn(
                    ft.Text(
                        "得意先CD",
                        weight=ft.FontWeight.BOLD,
                    )
                ),
                ft.DataColumn(
                    ft.Text(
                        "得意先名",
                        weight=ft.FontWeight.BOLD,
                    )
                ),
                ft.DataColumn(
                    ft.Text(
                        "ふりがな",
                        weight=ft.FontWeight.BOLD,
                    )
                ),
                ft.DataColumn(
                    ft.Text(
                        "別名",
                        weight=ft.FontWeight.BOLD,
                    )
                ),
                ft.DataColumn(
                    ft.Text(
                        "操作",
                        weight=ft.FontWeight.BOLD,
                    )
                ),
            ],
            rows=[
                ft.DataRow(
                    cells=[
                        ft.DataCell(
                            # 顧客CD
                            ft.Text(
                                "1",
                                selectable=True,
                            )
                        ),
                        ft.DataCell(
                            # 顧客名
                            ft.Text(
                                "顧客1",
                                selectable=True,
                            )
                        ),
                        ft.DataCell(
                            # ふりがな
                            ft.Text(
                                "こきゃく1",
                                selectable=True,
                            )
                        ),
                        ft.DataCell(
                            # 別名
                            ft.Text(
                                "顧客1別名",
                                selectable=True,
                            )
                        ),
                        ft.DataCell(
                            # 操作
                            ft.ElevatedButton(
                                "詳細",
                                on_click="hoge",
                                style=ft.ButtonStyle(
                                    shape=ft.RoundedRectangleBorder(radius=5)
                                ),
                            )
                        ),
                    ]
                )
            ],
        )

client_app.pyのdef _create_layout(self)にself.data_tableを追加。

client_app.py

def _create_layout(self):
        self.content = ft.Column(
            controls=[
                self.header,
                self.data_table, # 追加
            ]
        )

client_app.pyの初期化処理に、self._create_main_content()を追加。

client_app.py

def __init__(self, page):
        …
        # 初期化処理
        self._create_header()
        self._create_main_content() # 追加
        self._create_layout()

動作確認すると下記のようになる。self.headerとself.data_tableの区切りが見にくいので、containerの中に入れることにする。

client_app.pyのdef _create_layout(self)を下記のように修正する。

client_app.py

def _create_layout(self):
        self.content = ft.Column(
            controls=[
                ft.Container(
                    content=self.header,
                    bgcolor=ft.Colors.BLUE_100,
                ),
                ft.Container(
                    content=self.data_table,
                    bgcolor=ft.Colors.RED_100,
                ),
            ]
        )

self.data_tableを広げたいので、client_app.pyに下記のコードを追加。

client_app.py

def _create_layout(self):
        self.content = ft.Column(
            controls=[
                ft.Container(
                    content=self.header,
                    bgcolor=ft.Colors.BLUE_100,
                ),
                ft.Container(
                    content=self.data_table,
                    bgcolor=ft.Colors.RED_100,
                    expand=True, # 追加
                ),
            ],
            horizontal_alignment=ft.CrossAxisAlignment.STRETCH, # 追加
        )

詳細画面

顧客を詳細を表示するレイアウトを作成する。

client_datail.py

"""顧客詳細画面コンポーネント"""

import flet as ft


class ClientDetail(ft.Container):
    """顧客詳細画面クラス"""

    def __init__(self, page):
        super().__init__()
        self.page = page
        self.padding = 15
        self.bgcolor = ft.Colors.GREEN_100

        # 初期化処理
        self._create_header()
        self._create_main_content()
        self._create_layout()

    def _create_header(self):
        """ヘッダー部分を作成"""
        self.title = ft.Text(
            "顧客詳細",
            size=24,
            weight=ft.FontWeight.BOLD,
        )

        self.bnt_new_client = ft.ElevatedButton(
            "削除",
            icon=ft.Icons.DELETE,
            on_click="hoge",
            style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=5)),
        )

        self.back_to_list = ft.IconButton(
            tooltip="一覧に戻る",
            icon=ft.Icons.ARROW_BACK,
            on_click="hoge",
            style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=5)),
        )

        self.header = ft.Row(
            controls=[self.back_to_list, self.title, self.bnt_new_client]
        )

    def _create_main_content(self):
        """メイン部分を作成"""
        # 基本情報カード
        self.basic_info_card = ft.Card(
            content=ft.Column(
                controls=[
                    ft.Row(
                        controls=[
                            ft.Text(
                                "基本情報",
                                size=18,
                                weight=ft.FontWeight.BOLD,
                            ),
                            ft.ElevatedButton(
                                "編集",
                                icon=ft.Icons.EDIT,
                                on_click="hoge",
                                style=ft.ButtonStyle(
                                    shape=ft.RoundedRectangleBorder(radius=5)
                                ),
                            ),
                            ft.ElevatedButton(
                                "保存",
                                icon=ft.Icons.SAVE,
                                on_click="hoge",
                                style=ft.ButtonStyle(
                                    shape=ft.RoundedRectangleBorder(radius=5)
                                ),
                            ),
                            ft.ElevatedButton(
                                "キャンセル",
                                icon=ft.Icons.CANCEL,
                                on_click="hoge",
                                style=ft.ButtonStyle(
                                    shape=ft.RoundedRectangleBorder(radius=5)
                                ),
                            ),
                        ]
                    ),
                    ft.Divider(),
                    ft.Row(
                        controls=[
                            # 左側の列
                            ft.Column(
                                controls=[
                                    ft.TextField(
                                        label="顧客CD",
                                        width=400,
                                    ),
                                    ft.TextField(
                                        label="顧客名",
                                        width=400,
                                    ),
                                    ft.TextField(
                                        label="ふりがな",
                                        width=400,
                                    ),
                                    ft.TextField(
                                        label="別名",
                                        width=400,
                                    ),
                                    ft.TextField(
                                        label="代表者",
                                        width=400,
                                    ),
                                    ft.TextField(
                                        label="与信枠",
                                        width=400,
                                    ),
                                    ft.TextField(
                                        label="与信枠登録日",
                                        width=400,
                                    ),
                                ]
                            ),
                            # 右側の列
                            ft.Column(
                                controls=[
                                    ft.TextField(
                                        label="郵便番号",
                                        width=400,
                                    ),
                                    ft.TextField(
                                        label="住所",
                                        width=400,
                                    ),
                                    ft.TextField(
                                        label="TEL",
                                        width=400,
                                    ),
                                    ft.TextField(
                                        label="FAX",
                                        width=400,
                                    ),
                                    ft.TextField(
                                        label="備考",
                                        width=400,
                                        multiline=True,
                                        min_lines=3,
                                        max_lines=5,
                                    ),
                                ]
                            ),
                        ]
                    ),
                ],
            ),
            color=ft.Colors.YELLOW_200,
        )

    def _create_layout(self):
        self.content = ft.Column(
            controls=[
                ft.Container(
                    content=self.header,
                    bgcolor=ft.Colors.BLUE_100,
                ),
                ft.Container(
                    content=self.basic_info_card,
                    bgcolor=ft.Colors.RED_100,
                ),
            ],
        )

画面遷移はあとで作成するので、app_layout.pyを下記のように書き換えて動作確認。

app_layout.py

"""アプリケーション全体のレイアウト"""

import flet as ft
…
from client_detail import ClientDetail # 追加
…
def _load_main_content(self, menu_id):
        """メニューIDに応じたメインコンテンツを読み込む"""
        if menu_id == "clients":
            # return ClientManagementApp(self.page)
            return ClientDetail(self.page) # 追加

顧客CDと郵便番号の上の位置があってないので修正。client_detail.pyにvertical_alignment=ft.CrossAxisAlignment.STARTを追加。

client_detail.py

def _create_main_content(self):
        """メイン部分を作成"""
  ft.Row(
    vertical_alignment=ft.CrossAxisAlignment.START, # 追加
    controls=[
      # 左側の列
      ft.Column(

とりあえずレイアウトが出来たので、次回はデータベースの作成をする。