From 78ac4a28e39ce2695a907c4df445d9f4ba02a025 Mon Sep 17 00:00:00 2001 From: jSasaki Date: Fri, 26 Sep 2025 11:21:10 +0900 Subject: [PATCH 1/6] add-plan-setting-page --- src/App.tsx | 2 + src/pages/BillingDashboard.tsx | 48 ++- src/pages/PlanSettings.tsx | 755 +++++++++++++++++++++++++++++++++ src/pages/TenantList.tsx | 4 +- src/pages/UserPage.tsx | 9 + src/types/billing.ts | 45 +- src/types/index.ts | 23 +- src/utils.ts | 18 +- 8 files changed, 862 insertions(+), 42 deletions(-) create mode 100644 src/pages/PlanSettings.tsx diff --git a/src/App.tsx b/src/App.tsx index 7c133e3..055a9a4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import UserRegister from "./pages/UserRegister"; import DeleteUserLog from "./pages/DeleteUserLog"; import SelfSignUp from "./pages/SelfSignUp"; import BillingDashboard from "./pages/BillingDashboard"; +import PlanSettings from "./pages/PlanSettings"; import HeaderUserbox from "./components/header/HeaderUserbox"; import UserInvitation from "./pages/UserInvitation"; @@ -41,6 +42,7 @@ const AppContent = () => { } /> } /> } /> + } /> diff --git a/src/pages/BillingDashboard.tsx b/src/pages/BillingDashboard.tsx index bb4f24f..e94cbe4 100644 --- a/src/pages/BillingDashboard.tsx +++ b/src/pages/BillingDashboard.tsx @@ -2,14 +2,17 @@ import axios from "axios"; import { useEffect, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { API_ENDPOINT, LOGIN_URL } from "../const"; -import { idTokenCheck, randomUnixBetween, handleUserListClick } from "../utils"; +import { + idTokenCheck, + randomUnixBetween, + navigateToUserPageByRole, +} from "../utils"; +import { UserInfo, Tenant } from "../types"; import { BillingDashboardData, MeteringUnitBilling, PlanPeriodOption, - UserInfo, - Tenant, -} from "../types"; +} from "../types/billing"; import { Dialog, Transition } from "@headlessui/react"; import { Fragment } from "react"; import { XMarkIcon } from "@heroicons/react/24/outline"; @@ -50,7 +53,11 @@ const BillingDashboard = () => { Authorization: `Bearer ${jwtToken}`, "X-SaaSus-Referer": pagePath, // すべてのAPIでこの共通のパスを使用 }; - const getMeteringActionHeaders = (actionName: string, isAdd: boolean, value: number) => { + const getMeteringActionHeaders = ( + actionName: string, + isAdd: boolean, + value: number + ) => { const method = isAdd ? "add" : "sub"; return { ...commonHeaders, @@ -126,7 +133,10 @@ const BillingDashboard = () => { const seen = new Set(); const uniqueMeters: MeteringUnitBilling[] = []; for (const u of data.metering_unit_billings) { - if (u.metering_unit_type !== "fixed" && !seen.has(u.metering_unit_name)) { + if ( + u.metering_unit_type !== "fixed" && + !seen.has(u.metering_unit_name) + ) { seen.add(u.metering_unit_name); uniqueMeters.push(u); } @@ -229,10 +239,6 @@ const BillingDashboard = () => { if (!tenantId || !selectedPeriod) return; if (count <= 0) return; - const nowUnix = Math.floor(Date.now() / 1000); - const end = Math.min(selectedPeriod.end, nowUnix); - const ts = randomUnixBetween(selectedPeriod.start, end); - try { await axios.post( `${API_ENDPOINT}/billing/metering/${tenantId}/${meterName}`, @@ -241,7 +247,11 @@ const BillingDashboard = () => { count, }, { - headers: getMeteringActionHeaders("update_meter_inline", isAdd, count), + headers: getMeteringActionHeaders( + "update_meter_inline", + isAdd, + count + ), } ); @@ -291,16 +301,6 @@ const BillingDashboard = () => { } }, [roleName, selectedPeriod, periodOptions]); - const buildMeteringReferer = ( - prefix: string, - isAdd: boolean, - count: number - ): string => { - // ["add",3] or ["sub",1] という配列を文字列化 - const payload = JSON.stringify([isAdd ? "add" : "sub", count]); - return `${prefix}:${payload}`; - }; - const formatNumber = (num: number): string => { return num.toLocaleString(); }; @@ -573,7 +573,7 @@ const BillingDashboard = () => { - {/* ▼ 注意書き */} + {/* ▼ 注意書き */}
※Stripe連携を行っている場合、減算や過去のメータに対する変更はできません。 @@ -701,7 +701,9 @@ const BillingDashboard = () => { {/* ユーザー一覧へ戻るボタン */}
+ )} +
+
+
+ 現在のプラン: + {currentPlan?.display_name || "未設定"} +
+
+ 現在の税率: + + {tenantInfo?.tax_rate_id + ? getTaxRateDisplayName(tenantInfo.tax_rate_id) + : "未設定" + } + +
+ {/* 予約情報の表示 */} + {tenantInfo?.plan_reservation && ( + <> +
+
+
+

プラン変更予約

+ {/* 予約取り消しボタン: SaaSus Platformと同じ制御 */} + {deleteScheduleButtonIsVisible() && canEdit() && ( + + )} +
+
+
+ 予約プラン: + {getPlanDisplayName(tenantInfo.plan_reservation.next_plan_id || "")} +
+
+ 変更予定日時: + + {tenantInfo.plan_reservation.using_next_plan_from + ? new Date(tenantInfo.plan_reservation.using_next_plan_from * 1000).toLocaleString('ja-JP') + : "未設定" + } + +
+
+ 変更予定税率: + + {tenantInfo.plan_reservation.next_plan_tax_rate_id + ? getTaxRateDisplayName(tenantInfo.plan_reservation.next_plan_tax_rate_id) + : "未設定" + } + +
+
+
+
+ + )} +
+
+ + {/* プラン変更フォーム */} +
+
+

プラン変更

+
+ +
+ {/* プラン選択 */} +
+ + +
+ + {/* 税率選択 */} +
+ + +
+ + {/* 反映び */} +
+ +
+ + +
+ + {usingNextPlanFrom === "custom" && ( +
+ setCustomDate(e.target.value)} + min={new Date().toISOString().slice(0, 16)} + disabled={!canEdit()} + className={`w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${ + !canEdit() ? 'bg-gray-100 cursor-not-allowed' : '' + }`} + /> +
+ )} +
+ + {/* ボタン */} +
+ + +
+
+
+ + {/* 確認モーダル */} + {showConfirmModal && ( +
+
+

+ プラン変更の確認 +

+
+

+ 新しいプラン: + {getPlanDisplayName(selectedPlanId)} +

+ {selectedTaxRateId && ( +

+ 税率: + {getTaxRateDisplayName(selectedTaxRateId)} +

+ )} +

+ 反映日: + {usingNextPlanFrom === "immediate" + ? PLAN_SETTINGS_CONSTANTS.UI.IMMEDIATE_LABEL + : `${PLAN_SETTINGS_CONSTANTS.UI.CUSTOM_LABEL}(${customDate})` + } +

+
+
+ + +
+
+
+ )} + + {/* 完了モーダル */} + {showCompletedModal && ( +
+
+

+ プラン変更完了 +

+

+ {PLAN_SETTINGS_CONSTANTS.MESSAGES.PLAN_UPDATE_SUCCESS} + {usingNextPlanFrom === "immediate" + ? "5分後に新しいプランが適用されます。" + : `指定した日時に新しいプランが適用されます。` + } +

+
+ +
+
+
+ )} + + {/* プラン解除確認モーダル */} + {showCancelModal && ( +
+
+

+ プラン解除の確認 +

+

+ プランを解除すると、5分後に現在のプランが利用できなくなります。 + 本当に解除しますか? +

+
+ + +
+
+
+ )} + + {/* 予約取り消し確認モーダル */} + {showReservationCancelModal && ( +
+
+

+ 予約取り消しの確認 +

+

+ プラン変更予約を取り消しますか? + 取り消すと、現在のプランが継続されます。 +

+
+ + +
+
+
+ )} + + ); +}; + +export default PlanSettings; \ No newline at end of file diff --git a/src/pages/TenantList.tsx b/src/pages/TenantList.tsx index 1f98f06..218cf93 100644 --- a/src/pages/TenantList.tsx +++ b/src/pages/TenantList.tsx @@ -2,7 +2,7 @@ import axios from "axios"; import { useEffect, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { API_ENDPOINT } from "../const"; -import { idTokenCheck, handleUserListClick } from "../utils"; +import { idTokenCheck, navigateToUserPageByRole } from "../utils"; import { Tenant, UserInfo, TenantAttributesResponse } from "../types"; // テナント属性の値を適切にフォーマットする関数 @@ -111,7 +111,7 @@ const TenantList = () => { + + + + ); +}; + +export default ErrorDialog; \ No newline at end of file diff --git a/src/hooks/useErrorDialog.ts b/src/hooks/useErrorDialog.ts new file mode 100644 index 0000000..4816d72 --- /dev/null +++ b/src/hooks/useErrorDialog.ts @@ -0,0 +1,23 @@ +import { useState } from "react"; + +export const useErrorDialog = () => { + const [showErrorModal, setShowErrorModal] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const showError = (message: string) => { + setErrorMessage(message); + setShowErrorModal(true); + }; + + const hideError = () => { + setShowErrorModal(false); + setErrorMessage(""); + }; + + return { + showErrorModal, + errorMessage, + showError, + hideError, + }; +}; \ No newline at end of file diff --git a/src/pages/PlanSettings.tsx b/src/pages/PlanSettings.tsx index d2213f5..4924f83 100644 --- a/src/pages/PlanSettings.tsx +++ b/src/pages/PlanSettings.tsx @@ -1,10 +1,12 @@ import axios from "axios"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { API_ENDPOINT } from "../const"; import { idTokenCheck, navigateToUserPageByRole } from "../utils"; import { Tenant } from "../types"; import { PlanInfo, TaxRate, PricingPlan } from "../types/billing"; +import ErrorDialog from "../components/dialogs/ErrorDialog"; +import { useErrorDialog } from "../hooks/useErrorDialog"; // ユーティリティ関数 const formatToDateTimeLocal = (date: Date): string => { @@ -86,6 +88,7 @@ const PlanSettings = () => { const [showReservationCancelModal, setShowReservationCancelModal] = useState(false); const [reservationCancelLoading, setReservationCancelLoading] = useState(false); const [dateValidationError, setDateValidationError] = useState(""); + const { showErrorModal, errorMessage, showError, hideError } = useErrorDialog(); const navigate = useNavigate(); const location = useLocation(); @@ -183,10 +186,7 @@ const PlanSettings = () => { } }; - // エラー表示の統一処理 - const showError = (message: string) => { - alert(message); // 後でトーストやモーダルに置き換え可能 - }; + // エラー表示の統一処理(useErrorDialogフックから取得) // 汎用エラーハンドリング関数 const handleApiError = (error: unknown, fallbackMessage: string): string => { @@ -239,7 +239,10 @@ const PlanSettings = () => { // カスタム日時が指定されている場合のみ using_next_plan_from を設定 if (usingNextPlanFrom === "custom" && customDate) { - updateData.using_next_plan_from = Math.floor(new Date(customDate).getTime() / 1000); + const date = new Date(customDate); + if (!isNaN(date.getTime())) { + updateData.using_next_plan_from = Math.floor(date.getTime() / 1000); + } } return updateData; @@ -401,7 +404,8 @@ const PlanSettings = () => { setReservationCancelLoading(true); try { - // 予約を取り消すために、プラン更新APIを空のリクエストボディで呼び出す + // NOTE: 空オブジェクトでの予約取り消しはSaaSus APIの仕様に合わせている + // バックエンドとフロントエンドで一貫性を保つため、同じ実装を採用 await axios.put( `${API_ENDPOINT}/tenants/${tenantId}/plan`, {}, @@ -591,7 +595,7 @@ const PlanSettings = () => { - {/* 反映び */} + {/* 反映日 */}
)} + + {/* エラーダイアログ */} + ); };