Merged PR 379: 画面実装(パートナー一覧画面本実装)

## 概要
[Task2539: 画面実装(パートナー一覧画面本実装)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2539)

- 元PBI or タスクへのリンク(内容・目的などはそちらにあるはず)
- 何をどう変更したか、追加したライブラリなど
パートナー一覧画面でパートナーの一覧が表示されるように実装

- このPull Requestでの対象/対象外
・Add Accountボタンは前PBIのため対象外
・Dealer Managementボタンの挙動は対象外
・Delete Accountボタンの挙動は対象外

- 影響範囲(他の機能にも影響があるか)
特になし

## レビューポイント
- 特にレビューしてほしい箇所
・Dealer Management、Delete Accountボタンの表示制御
・ページネーション

- 軽微なものや自明なものは記載不要
- 修正範囲が大きい場合などに記載
- 全体的にや仕様を満たしているか等は本当に必要な時のみ記載

## UIの変更
- Before/Afterのスクショなど
- スクショ置き場
https://ndstokyo.sharepoint.com/:f:/r/sites/Piranha/Shared%20Documents/General/OMDS/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88/Task2539?csf=1&web=1&e=PNI5bw

## 動作確認状況
- ローカルで確認

## 補足
- 相談、参考資料などがあれば
This commit is contained in:
水本 祐希 2023-09-12 01:14:26 +00:00
parent 2dcb1c1f84
commit 606ff6de9b
8 changed files with 354 additions and 75 deletions

View File

@ -0,0 +1 @@
export const LIMIT_PARTNER_VIEW_NUM = 15;

View File

@ -2,3 +2,4 @@ export * from "./state";
export * from "./operations";
export * from "./selectors";
export * from "./partnerSlice";
export * from "./constants";

View File

@ -3,7 +3,11 @@ import type { RootState } from "app/store";
import { ErrorObject, createErrorObject } from "common/errors";
import { getTranslationID } from "translation";
import { openSnackbar } from "features/ui/uiSlice";
import { AccountsApi, CreatePartnerAccountRequest } from "../../api/api";
import {
AccountsApi,
CreatePartnerAccountRequest,
GetPartnersResponse,
} from "../../api/api";
import { Configuration } from "../../api/configuration";
export const createPartnerAccountAsync = createAsyncThunk<
@ -62,3 +66,53 @@ export const createPartnerAccountAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
// パートナー一覧取得APIからパートナーのアカウント情報をもらう
export const getPartnerInfoAsync = createAsyncThunk<
// 正常時の戻り値の型
GetPartnersResponse,
{
// パラメータ
limit: number;
offset: number;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("partner/getPartnerInfoAsync", async (args, thunkApi) => {
const { limit, offset } = args;
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration, accessToken } = state.auth;
const config = new Configuration(configuration);
const accountsApi = new AccountsApi(config);
try {
const res = await accountsApi.getPartners(limit, offset, {
headers: { authorization: `Bearer ${accessToken}` },
});
const ret = {
partners: res.data.partners,
total: res.data.total,
};
return ret;
} catch (e) {
const error = createErrorObject(e);
const errorMessage =
error.code === "E000108"
? getTranslationID("common.message.permissionDeniedError")
: getTranslationID("common.message.internalServerError");
thunkApi.dispatch(
openSnackbar({
level: "error",
message: errorMessage,
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -1,8 +1,15 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { PartnerState } from "./state";
import { createPartnerAccountAsync } from "./operations";
import { createPartnerAccountAsync, getPartnerInfoAsync } from "./operations";
import { LIMIT_PARTNER_VIEW_NUM } from "./constants";
const initialState: PartnerState = {
domain: {
getPartnersInfo: {
total: 0,
partners: [],
},
},
apps: {
addPartner: {
companyName: "",
@ -10,6 +17,8 @@ const initialState: PartnerState = {
adminName: "",
email: "",
},
limit: LIMIT_PARTNER_VIEW_NUM,
offset: 0,
isLoading: false,
},
};
@ -37,6 +46,20 @@ export const partnerSlice = createSlice({
cleanupAddPartner: (state) => {
state.apps.addPartner = initialState.apps.addPartner;
},
cleanupApps: (state) => {
state.domain = initialState.domain;
},
savePageInfo: (
state,
action: PayloadAction<{
limit: number;
offset: number;
}>
) => {
const { limit, offset } = action.payload;
state.apps.limit = limit;
state.apps.offset = offset;
},
},
extraReducers: (builder) => {
builder.addCase(createPartnerAccountAsync.pending, (state) => {
@ -48,6 +71,17 @@ export const partnerSlice = createSlice({
builder.addCase(createPartnerAccountAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(getPartnerInfoAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(getPartnerInfoAsync.fulfilled, (state, action) => {
state.domain.getPartnersInfo.total = action.payload.total;
state.domain.getPartnersInfo.partners = action.payload.partners;
state.apps.isLoading = false;
});
builder.addCase(getPartnerInfoAsync.rejected, (state) => {
state.apps.isLoading = false;
});
},
});
export const {
@ -56,5 +90,6 @@ export const {
changeCompany,
changeCountry,
cleanupAddPartner,
savePageInfo,
} = partnerSlice.actions;
export default partnerSlice.reducer;

View File

@ -1,4 +1,5 @@
import { RootState } from "app/store";
import { ceil, floor } from "lodash";
export const selectInputValidationErrors = (state: RootState) => {
// 必須項目のチェック
@ -33,3 +34,22 @@ export const selectEmail = (state: RootState) =>
state.partner.apps.addPartner.email;
export const selectIsLoading = (state: RootState) =>
state.partner.apps.isLoading;
export const selectPartnersInfo = (state: RootState) =>
state.partner.domain.getPartnersInfo;
export const selectTotal = (state: RootState) =>
state.partner.domain.getPartnersInfo.total;
export const seletctLimit = (state: RootState) => state.partner.apps.limit;
export const selectOffset = (state: RootState) => state.partner.apps.offset;
export const selectTotalPage = (state: RootState) => {
const { limit } = state.partner.apps;
const { total } = state.partner.domain.getPartnersInfo;
const page = ceil(total / limit);
return page;
};
export const selectCurrentPage = (state: RootState) => {
const { limit, offset } = state.partner.apps;
const page = floor(offset / limit) + 1;
return page;
};

View File

@ -1,10 +1,20 @@
import { CreatePartnerAccountRequest } from "../../api/api";
import {
CreatePartnerAccountRequest,
GetPartnersResponse,
} from "../../api/api";
export interface PartnerState {
domain: Domain;
apps: Apps;
}
export interface Domain {
getPartnersInfo: GetPartnersResponse;
}
export interface Apps {
limit: number;
offset: number;
addPartner: CreatePartnerAccountRequest;
isLoading: boolean;
}

View File

@ -1,44 +1,89 @@
import { useMsal } from "@azure/msal-react";
/* eslint-disable jsx-a11y/control-has-associated-label */
import { AppDispatch } from "app/store";
import { UpdateTokenTimer } from "components/auth/updateTokenTimer";
import Footer from "components/footer";
import Header from "components/header";
import { clearToken } from "features/auth";
import React, { useCallback, useState } from "react";
import { useDispatch } from "react-redux";
import React, { useCallback, useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import styles from "styles/app.module.scss";
import { loadAccessToken, isApproveTier } from "features/auth/utils";
import postAdd from "../../assets/images/post_add.svg";
import { decodeToken } from "../../common/decodeToken";
import { isApproveTier } from "features/auth/utils";
import {
LIMIT_PARTNER_VIEW_NUM,
selectCurrentPage,
selectIsLoading,
selectOffset,
selectTotal,
selectTotalPage,
getPartnerInfoAsync,
selectPartnersInfo,
} from "features/partner/index";
import { savePageInfo } from "features/partner/partnerSlice";
import { getTranslationID } from "translation";
import { useTranslation } from "react-i18next";
import personAdd from "../../assets/images/person_add.svg";
import { TIERS } from "../../components/auth/constants";
import { AddPartnerAccountPopup } from "./addPartnerAccountPopup";
import checkFill from "../../assets/images/check_fill.svg";
const PartnerPage: React.FC = (): JSX.Element => {
const { instance } = useMsal();
const dispatch: AppDispatch = useDispatch();
const [isPopupOpen, setIsPopupOpen] = useState(false);
const [t] = useTranslation();
const total = useSelector(selectTotal);
const totalPage = useSelector(selectTotalPage);
const offset = useSelector(selectOffset);
const currentPage = useSelector(selectCurrentPage);
const isLoading = useSelector(selectIsLoading);
/* XXX
POデモ時に階層情報を表示するための実装です */
const getUserTier = () => {
const jwt = loadAccessToken(); // トークンを取得
const token = jwt && decodeToken(jwt); // トークンをデコード
// apiからの値取得関係
const partnerInfo = useSelector(selectPartnersInfo);
if (token && token.tier) {
return token.tier.toString(); // ユーザーの階層情報を取得
}
return "error!"; // 階層情報が見つからない場合はerror!を返す
// 階層表示用
const tierNames: { [key: number]: string } = {
// eslint-disable-next-line @typescript-eslint/naming-convention
1: t(getTranslationID("common.label.tier1")),
// eslint-disable-next-line @typescript-eslint/naming-convention
2: t(getTranslationID("common.label.tier2")),
// eslint-disable-next-line @typescript-eslint/naming-convention
3: t(getTranslationID("common.label.tier3")),
// eslint-disable-next-line @typescript-eslint/naming-convention
4: t(getTranslationID("common.label.tier4")),
// eslint-disable-next-line @typescript-eslint/naming-convention
5: t(getTranslationID("common.label.tier5")),
};
/* XXX
*/
const userTier = getUserTier();
// 第13階層にボタンを表示する
const isVisible = isApproveTier([TIERS.TIER1, TIERS.TIER2, TIERS.TIER3]);
const isVisibleButton = isApproveTier([
TIERS.TIER1,
TIERS.TIER2,
TIERS.TIER3,
]);
// 第4階層でdealerManagementを表示
const isVisibleDealerManagement = isApproveTier([TIERS.TIER4]);
const onOpen = useCallback(() => {
setIsPopupOpen(true);
}, [setIsPopupOpen]);
// パートナー取得APIを呼び出す
useEffect(() => {
dispatch(
getPartnerInfoAsync({
limit: LIMIT_PARTNER_VIEW_NUM,
offset,
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, currentPage]);
// ページネーションのボタンクリック時のアクション
const movePage = (targetOffset: number) => {
dispatch(
savePageInfo({ limit: LIMIT_PARTNER_VIEW_NUM, offset: targetOffset })
);
};
// HTML
return (
<>
@ -52,52 +97,167 @@ const PartnerPage: React.FC = (): JSX.Element => {
<Header userName="XXXXXX" />
<UpdateTokenTimer />
<main className={styles.main}>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>
{t(getTranslationID("partnerPage.label.title"))}
</h1>
</div>
<section className={styles.partners}>
<div>
<ul className={styles.menuAction}>
<li>
{isVisible && (
{isVisibleButton && (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<a
className={`${styles.menuLink} ${styles.isActive}`}
onClick={onOpen}
>
<img src={postAdd} alt="" className={styles.menuIcon} />
Add Account
<img src={personAdd} alt="" className={styles.menuIcon} />
{t(getTranslationID("partnerPage.label.addAccount"))}
</a>
)}
</li>
</ul>
<form className={styles.form}>
<dl className={`${styles.formList} ${styles.hasbg}`}>
<dt className={styles.formTitle} />
<dt />
<dd className="">
<input
type="text"
size={40}
name=""
value={`Tier:${userTier}`}
maxLength={20}
className={styles.formInput}
<table
className={`${styles.table} ${styles.partner} ${
styles.marginBtm3
} ${isVisibleDealerManagement ? styles.role4 : ""}`}
>
<tr className={styles.tableHeader}>
<th className={styles.clm0}>{/** th is empty */}</th>
<th>
<a>{t(getTranslationID("partnerPage.label.name"))}</a>
</th>
<th>
<a>{t(getTranslationID("partnerPage.label.category"))}</a>
</th>
<th>
<a>{t(getTranslationID("partnerPage.label.accountId"))}</a>
</th>
<th>
<a>{t(getTranslationID("partnerPage.label.country"))}</a>
</th>
<th>
<a>
{t(getTranslationID("partnerPage.label.primaryAdmin"))}
</a>
</th>
<th>
<a>{t(getTranslationID("partnerPage.label.email"))}</a>
</th>
<th>
<a>
{t(
getTranslationID("partnerPage.label.dealerManagement")
)}
</a>
</th>
</tr>
{!isLoading &&
partnerInfo.partners.length !== 0 &&
partnerInfo.partners.map((x) => (
// eslint-disable-next-line react/jsx-key
<tr>
<td className={styles.clm0}>
<ul className={styles.menuInTable}>
<li>
{isVisibleButton && (
<a>
{t(
getTranslationID(
"partnerPage.label.deleteAccount"
)
)}
</a>
)}
</li>
</ul>
</td>
<td>{x.name}</td>
<td>{tierNames[x.tier]}</td>
<td>{x.accountId}</td>
<td>{x.country}</td>
<td>{x.primaryAdmin ?? "-"}</td>
<td>{x.email ?? "-"}</td>
<td>
<img
src={checkFill}
alt=""
className={styles.icCheckCircle}
/>
</dd>
</dl>
</form>
</main>
<div>
<button
type="button"
className={styles.buttonText}
</td>
</tr>
))}
</table>
{/** pagenation */}
<div className={styles.pagenation}>
<nav className={styles.pagenationNav}>
<span className={styles.pagenationTotal}>
{`${total} ${t(
getTranslationID("partnerPage.label.partners")
)}`}
</span>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<a
className={`${
!isLoading && currentPage !== 1 ? styles.isActive : ""
}`}
onClick={() => {
instance.logout({ postLogoutRedirectUri: "/" });
dispatch(clearToken());
movePage(0);
}}
>
sign out
</button>
«
</a>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a
className={`${
!isLoading && currentPage !== 1 ? styles.isActive : ""
}`}
onClick={() => {
movePage((currentPage - 2) * LIMIT_PARTNER_VIEW_NUM);
}}
>
</a>
{` ${total !== 0 ? currentPage : 0} of ${
total !== 0 ? totalPage : 0
} `}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a
className={`${
!isLoading && currentPage < totalPage
? styles.isActive
: ""
}`}
onClick={() => {
movePage(currentPage * LIMIT_PARTNER_VIEW_NUM);
}}
>
</a>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<a
className={`${
!isLoading && currentPage < totalPage
? styles.isActive
: ""
}`}
onClick={() => {
movePage((totalPage - 1) * LIMIT_PARTNER_VIEW_NUM);
}}
>
»
</a>
</nav>
</div>
</div>
</section>
</main>
<Footer />
</div>
</>
);
};
export default PartnerPage;

View File

@ -2165,8 +2165,7 @@ tr.isSelected .menuInTable li a {
}
.formChange ul.chooseMember li input + label:hover,
.formChange ul.holdMember li input + label:hover {
background: #e6e6e6 url(../assets/images/arrow_circle_left.svg) no-repeat left
center;
background: #e6e6e6 url(../assets/images/arrow_circle_left.svg) no-repeat left center;
background-size: 1.3rem;
}
.formChange ul.chooseMember li input:checked + label,
@ -2177,8 +2176,8 @@ tr.isSelected .menuInTable li a {
}
.formChange ul.chooseMember li input:checked + label:hover,
.formChange ul.holdMember li input:checked + label:hover {
background: #e6e6e6 url(../assets/images/arrow_circle_right.svg) no-repeat
right center;
background: #e6e6e6 url(../assets/images/arrow_circle_right.svg) no-repeat right
center;
background-size: 1.3rem;
}
.formChange > p {
@ -2337,8 +2336,7 @@ tr.isSelected .menuInTable li a {
}
.formChange ul.chooseMember li input + label:hover,
.formChange ul.holdMember li input + label:hover {
background: #e6e6e6 url(../assets/images/arrow_circle_left.svg) no-repeat left
center;
background: #e6e6e6 url(../assets/images/arrow_circle_left.svg) no-repeat left center;
background-size: 1.3rem;
}
.formChange ul.chooseMember li input:checked + label,
@ -2349,8 +2347,8 @@ tr.isSelected .menuInTable li a {
}
.formChange ul.chooseMember li input:checked + label:hover,
.formChange ul.holdMember li input:checked + label:hover {
background: #e6e6e6 url(../assets/images/arrow_circle_right.svg) no-repeat
right center;
background: #e6e6e6 url(../assets/images/arrow_circle_right.svg) no-repeat right
center;
background-size: 1.3rem;
}
.formChange > p {