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/components/dialogs/ErrorDialog.tsx b/src/components/dialogs/ErrorDialog.tsx new file mode 100644 index 0000000..4ff5bb6 --- /dev/null +++ b/src/components/dialogs/ErrorDialog.tsx @@ -0,0 +1,35 @@ +import React from "react"; + +interface ErrorDialogProps { + open: boolean; + message: string; + onClose: () => void; +} + +const ErrorDialog: React.FC = ({ open, message, onClose }) => { + if (!open) return null; + + return ( +
+
+
+ + + +

エラー

+
+

{message}

+
+ +
+
+
+ ); +}; + +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/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" && ( +
+ { + const selectedDate = e.target.value; + + // ユーザーが入力を開始したらエラーメッセージをクリア + setDateValidationError(""); + + const fiveMinutesLater = new Date(Date.now() + PLAN_SETTINGS_CONSTANTS.DELAYS.MIN_DATETIME_MINUTES * 60 * 1000); + const minDateString = formatToDateTimeLocal(fiveMinutesLater); + + // 選択された日時が5分後より前の場合は5分後に設定 + if (selectedDate < minDateString) { + setCustomDate(minDateString); + setDateValidationError(`5分後の日時より前は選択できません。${minDateString}に設定しました。`); + } else { + setCustomDate(selectedDate); + } + }} + onBlur={(e) => { + const selectedDate = e.target.value; + const fiveMinutesLater = new Date(Date.now() + PLAN_SETTINGS_CONSTANTS.DELAYS.MIN_DATETIME_MINUTES * 60 * 1000); + const minDateString = formatToDateTimeLocal(fiveMinutesLater); + + // フォーカスが外れた時にもバリデーション(テキスト入力時用) + if (selectedDate && selectedDate < minDateString) { + setCustomDate(minDateString); + setDateValidationError(`5分後の日時より前は選択できません。${minDateString}に設定しました。`); + } + }} + min={formatToDateTimeLocal(new Date(Date.now() + PLAN_SETTINGS_CONSTANTS.DELAYS.MIN_DATETIME_MINUTES * 60 * 1000))} + 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' : '' + }`} + /> + {dateValidationError && ( +
+ {dateValidationError} +
+ )} +
+ )} +
+ + {/* ボタン */} +
+ + +
+
+
+ + {/* 確認モーダル */} + {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 = () => {