Merged PR 156: タイピスト割り当てポップアップ実装&画面修正

## 概要
[Task1933: タイピスト割り当てポップアップ実装&画面修正](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/1933)

- タイピスト割り当てポップアップを実装しました。
  - ポップアップをタスクがUploaded時のみ開けるようにしています。
  - タイピスト割り当てを変更・保存できるようにしています。

## レビューポイント
- 画面実装のデザインは適切か
- データの保持方法は適切か

## UIの変更
- [Task1933](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/Task1933?csf=1&web=1&e=4ghlqe)

## 動作確認状況
- ローカルで確認
  - API連携は未確認
This commit is contained in:
makabe.t 2023-06-23 04:57:54 +00:00
parent 6a1226c62e
commit 33fc741eee
13 changed files with 1290 additions and 721 deletions

View File

@ -361,72 +361,72 @@ export interface GetLicenseSummaryRequest {
* @interface GetLicenseSummaryResponse
*/
export interface GetLicenseSummaryResponse {
/**
*
* @type {LicenseSummaryInfo}
* @memberof GetLicenseSummaryResponse
*/
totalLicense: number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
allocatedLicense: number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
reusableLicense: number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
freeLicense: number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
expiringWithin14daysLicense: number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
issueRequesting: number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
numberOfRequesting: number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
shortage: number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
storageSize: number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
usedSize: number;
/**
*
* @type {boolean}
* @memberof GetLicenseSummaryResponse
*/
isAccountLock: boolean;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
'totalLicense': number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
'allocatedLicense': number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
'reusableLicense': number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
'freeLicense': number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
'expiringWithin14daysLicense': number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
'issueRequesting': number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
'numberOfRequesting': number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
'shortage': number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
'storageSize': number;
/**
*
* @type {number}
* @memberof GetLicenseSummaryResponse
*/
'usedSize': number;
/**
*
* @type {boolean}
* @memberof GetLicenseSummaryResponse
*/
'isAccountLock': boolean;
}
/**
*

View File

@ -25,4 +25,5 @@ export const errorCodes = [
"E010301", // メールアドレス登録済みエラー
"E010302", // authorId重複エラー
"E010401", // PONumber重複エラー
"E010601", // タスク変更不可エラー
] as const;

View File

@ -1,6 +1,6 @@
import { ConfigurationParameters } from "api";
import { decodeToken } from "../../common/decodeToken";
import { ADMIN_ROLES } from "../../components/auth/constants";
import { ADMIN_ROLES, USER_ROLES } from "../../components/auth/constants";
/**
* Get access token
@ -66,3 +66,16 @@ export const isAdminUser = (): boolean => {
}
return token.role.includes(ADMIN_ROLES.ADMIN);
};
/**
* is author user Authorかどうかを返す
* @returns bool
*/
export const isAuthorUser = (): boolean => {
const jwt = loadAccessToken();
const token = jwt ? decodeToken(jwt) : null;
if (!token) {
return false;
}
return token.role.includes(USER_ROLES.AUTHOR);
};

View File

@ -1,6 +1,12 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import { Assignee, Task } from "api/api";
import { DictationState } from "./state";
import { getSortColumnAsync, listTasksAsync } from "./operations";
import {
getSortColumnAsync,
listTasksAsync,
listTypistGroupsAsync,
listTypistsAsync,
} from "./operations";
import {
SORTABLE_COLUMN,
DIRECTION,
@ -17,11 +23,18 @@ const initialState: DictationState = {
offset: 0,
total: 0,
tasks: [],
typists: [],
typistGroups: [],
},
apps: {
displayInfo: INIT_DISPLAY_INFO,
direction: DIRECTION.ASC,
paramName: SORTABLE_COLUMN.JobNumber,
selectedTask: undefined,
assignee: {
selected: [],
pool: [],
},
},
};
@ -50,6 +63,37 @@ export const dictationSlice = createSlice({
const { paramName } = action.payload;
state.apps.paramName = paramName;
},
changeSelectedTask: (state, action: PayloadAction<{ task: Task }>) => {
const { task } = action.payload;
state.apps.selectedTask = task;
},
changeAssignee: (
state,
action: PayloadAction<{ selected: Assignee[] }>
) => {
const { selected } = action.payload;
const typists = state.domain.typists.map(
(x) => ({ typistUserId: x.id, typistName: x.name } as Assignee)
);
const typistGroups = state.domain.typistGroups.map(
(x) => ({ typistGroupId: x.id, typistName: x.name } as Assignee)
);
const transcriptionists = [...typists, ...typistGroups];
const pool = transcriptionists.filter(
(x) =>
selected.find(
(assign) =>
assign.typistGroupId === x.typistGroupId &&
assign.typistUserId === x.typistUserId
) === undefined
);
state.apps.assignee.selected = selected;
state.apps.assignee.pool = pool;
},
},
extraReducers: (builder) => {
builder.addCase(listTasksAsync.fulfilled, (state, action) => {
@ -62,10 +106,21 @@ export const dictationSlice = createSlice({
state.apps.direction = action.payload.direction;
state.apps.paramName = action.payload.paramName;
});
builder.addCase(listTypistsAsync.fulfilled, (state, action) => {
state.domain.typists = action.payload.typists;
});
builder.addCase(listTypistGroupsAsync.fulfilled, (state, action) => {
state.domain.typistGroups = action.payload.typistGroups;
});
},
});
export const { changeDisplayInfo, changeDirection, changeParamName } =
dictationSlice.actions;
export const {
changeDisplayInfo,
changeDirection,
changeParamName,
changeSelectedTask,
changeAssignee,
} = dictationSlice.actions;
export default dictationSlice.reducer;

View File

@ -2,7 +2,15 @@ import { createAsyncThunk } from "@reduxjs/toolkit";
import type { RootState } from "app/store";
import { getTranslationID } from "translation";
import { openSnackbar } from "features/ui/uiSlice";
import { TasksResponse, TasksApi, UsersApi } from "../../api/api";
import {
TasksResponse,
TasksApi,
UsersApi,
AccountsApi,
GetTypistsResponse,
GetTypistGroupsResponse,
Assignee,
} from "../../api/api";
import { Configuration } from "../../api/configuration";
import { ErrorObject, createErrorObject } from "../../common/errors";
import {
@ -162,3 +170,133 @@ export const updateSortColumnAsync = createAsyncThunk<
return thunkApi.rejectWithValue({ error });
}
});
export const listTypistsAsync = createAsyncThunk<
GetTypistsResponse,
void,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/listTypistsAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
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 typists = await accountsApi.getTypists({
headers: { authorization: `Bearer ${accessToken}` },
});
return typists.data;
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const listTypistGroupsAsync = createAsyncThunk<
GetTypistGroupsResponse,
void,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/listTypistGroupsAsync", async (args, thunkApi) => {
// apiのConfigurationを取得する
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 typistGroup = await accountsApi.getTypistGroups({
headers: { authorization: `Bearer ${accessToken}` },
});
return typistGroup.data;
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const updateAssigneeAsync = createAsyncThunk<
{
/** empty */
},
{
audioFileId: number;
assignees: Assignee[];
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("dictations/updateAssigneeAsync", async (args, thunkApi) => {
const { audioFileId, assignees } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration, accessToken } = state.auth;
const config = new Configuration(configuration);
const tasksApi = new TasksApi(config);
try {
await tasksApi.changeCheckoutPermission(
audioFileId,
{ assignees },
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
return {};
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
// ステータスがUploaded以外、タスクが存在しない場合
if (error.code === "E010601") {
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("dictationPage.message.taskNotEditable"),
})
);
return thunkApi.rejectWithValue({ error });
}
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -29,3 +29,12 @@ export const selectDirection = (state: RootState) =>
export const selectParamName = (state: RootState) =>
state.dictation.apps.paramName;
export const selectSelectedTask = (state: RootState) =>
state.dictation.apps.selectedTask;
export const selectSelectedTranscriptionists = (state: RootState) =>
state.dictation.apps.assignee.selected;
export const selectPoolTranscriptionists = (state: RootState) =>
state.dictation.apps.assignee.pool;

View File

@ -1,4 +1,4 @@
import { Task } from "../../api/api";
import { Task, Assignee, Typist, TypistGroup } from "../../api/api";
import {
DirectionType,
DisplayInfoType,
@ -15,10 +15,17 @@ export interface Domain {
offset: number;
total: number;
tasks: Task[];
typists: Typist[];
typistGroups: TypistGroup[];
}
export interface Apps {
displayInfo: DisplayInfoType;
direction: DirectionType;
paramName: SortableColumnType;
selectedTask?: Task;
assignee: {
selected: Assignee[];
pool: Assignee[];
};
}

View File

@ -0,0 +1,194 @@
import React, { useCallback } from "react";
import styles from "styles/app.module.scss";
import { useDispatch, useSelector } from "react-redux";
import {
changeAssignee,
selectPoolTranscriptionists,
selectSelectedTask,
selectSelectedTranscriptionists,
updateAssigneeAsync,
} from "features/dictation";
import { Assignee } from "api";
import { AppDispatch } from "app/store";
import { getTranslationID } from "translation";
import { useTranslation } from "react-i18next";
import close from "../../assets/images/close.svg";
interface ChangeTranscriptionistPopupProps {
onClose: (isChanged: boolean) => void;
isOpen: boolean;
}
export const ChangeTranscriptionistPopup: React.FC<
ChangeTranscriptionistPopupProps
> = (props) => {
const { onClose, isOpen } = props;
const dispatch: AppDispatch = useDispatch();
const [t] = useTranslation();
// ポップアップを閉じる処理
const closePopup = useCallback(() => {
onClose(false);
}, [onClose]);
const selectedTask = useSelector(selectSelectedTask);
const selectedTranscriptionists = useSelector(
selectSelectedTranscriptionists
);
const poolTranscriptionists = useSelector(selectPoolTranscriptionists);
const removeAssignee = useCallback(
(assignee: Assignee) => {
dispatch(
changeAssignee({
selected: selectedTranscriptionists.filter(
(x) =>
x.typistGroupId !== assignee.typistGroupId ||
x.typistUserId !== assignee.typistUserId
),
})
);
},
[dispatch, selectedTranscriptionists]
);
const addAssignee = useCallback(
(assignee: Assignee) => {
dispatch(
changeAssignee({ selected: [...selectedTranscriptionists, assignee] })
);
},
[dispatch, selectedTranscriptionists]
);
const onChangeTranscriptionist = useCallback(async () => {
// ダイアログ確認
if (
!selectedTask ||
/* eslint-disable-next-line no-alert */
!window.confirm(t(getTranslationID("common.message.dialogConfirm")))
) {
return;
}
const { meta } = await dispatch(
updateAssigneeAsync({
audioFileId: selectedTask.audioFileId,
assignees: selectedTranscriptionists,
})
);
if (meta.requestStatus === "fulfilled") {
onClose(true);
}
}, [dispatch, selectedTranscriptionists, selectedTask, onClose, t]);
return (
<div className={`${styles.modal} ${isOpen ? styles.isShow : ""}`}>
<div className={styles.modalBox}>
<p className={styles.modalTitle}>
{t(getTranslationID("dictationPage.label.changeTranscriptionist"))}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-noninteractive-element-interactions */}
<img
src={close}
className={styles.modalTitleIcon}
alt="close"
onClick={closePopup}
/>
</p>
<form action="" name="" method="" className={styles.form}>
<dl className={`${styles.formList} ${styles.hasbg}`}>
<dt className={styles.formTitle} />
<dt>{t(getTranslationID("dictationPage.label.jobNumber"))}</dt>
<dd>
<input
type="text"
size={40}
value={selectedTask?.jobNumber ?? ""}
className={styles.formInput}
readOnly
/>
</dd>
<dt>{t(getTranslationID("dictationPage.label.authorId"))}</dt>
<dd>
<input
type="text"
size={40}
value={selectedTask?.authorId ?? ""}
className={styles.formInput}
readOnly
/>
</dd>
<dt>{t(getTranslationID("dictationPage.label.workType"))}</dt>
<dd>
<input
type="text"
size={40}
value={selectedTask?.workType ?? ""}
className={styles.formInput}
readOnly
/>
</dd>
<dt className={styles.formTitle}>Transcriptionist</dt>
<dd className={`${styles.formChange} ${styles.last}`}>
<ul className={styles.chooseMember}>
<li className={styles.changeTitle}>
{t(
getTranslationID(
"dictationPage.label.selectedTranscriptionist"
)
)}
</li>
{selectedTranscriptionists?.map((x) => (
<li key={`${x.typistGroupId ?? 0}_${x.typistUserId ?? 0}`}>
<input
type="checkbox"
className={styles.formCheck}
value={x.typistName}
id={x.typistName}
checked
onClick={() => removeAssignee(x)}
/>
<label htmlFor={x.typistName} title="Remove">
{x.typistName}
</label>
</li>
))}
</ul>
<p />
<ul className={styles.holdMember}>
<li className={styles.changeTitle}>
{t(
getTranslationID("dictationPage.label.poolTranscriptionist")
)}
</li>
{poolTranscriptionists?.map((x) => (
<li key={`${x.typistGroupId ?? 0}_${x.typistUserId ?? 0}`}>
<input
type="checkbox"
className={styles.formCheck}
value={x.typistName}
id={x.typistName}
onClick={() => addAssignee(x)}
/>
<label htmlFor={x.typistName} title="Add">
{x.typistName}
</label>
</li>
))}
</ul>
</dd>
<dd className={`${styles.full} ${styles.alignCenter}`}>
<input
type="button"
name="submit"
value={t(getTranslationID("dictationPage.label.saveChanges"))}
className={`${styles.formSubmit} ${styles.marginBtm1} ${styles.isActive}`}
onClick={onChangeTranscriptionist}
/>
</dd>
</dl>
</form>
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,8 @@
"passwordIncorrectError": "(de)Error Message",
"emailIncorrectError": "(de)Error Message",
"internalServerError": "(de)処理に失敗しました。時間をおいて再実行しても解決しない場合はシステム管理者にお問い合わせください。",
"listEmpty": "(de)検索結果が0件です。"
"listEmpty": "(de)検索結果が0件です。",
"dialogConfirm": "(de)操作を実行しますか?"
},
"label": {
"cancel": "(de)Cancel",
@ -171,6 +172,9 @@
}
},
"dictationPage": {
"message": {
"taskNotEditable": "(de)すでに文字起こし作業着手中またはタスクが存在しないため、タイピストを変更できません。"
},
"label": {
"title": "(de)Dictations",
"displayInfomation": "(de)Display Information",
@ -209,7 +213,10 @@
"playback": "(de)Playback",
"fileProperty": "(de)File Property",
"changeTranscriptionist": "(de)Change Transcriptionist",
"deleteDictation": "(de)Delete Dictation"
"deleteDictation": "(de)Delete Dictation",
"selectedTranscriptionist": "(de)Selected",
"poolTranscriptionist": "(de)Pool",
"saveChanges": "(de)Save changes"
}
}
}

View File

@ -5,7 +5,8 @@
"passwordIncorrectError": "Error Message",
"emailIncorrectError": "Error Message",
"internalServerError": "処理に失敗しました。時間をおいて再実行しても解決しない場合はシステム管理者にお問い合わせください。",
"listEmpty": "検索結果が0件です。"
"listEmpty": "検索結果が0件です。",
"dialogConfirm": "操作を実行しますか?"
},
"label": {
"cancel": "Cancel",
@ -171,6 +172,9 @@
}
},
"dictationPage": {
"message": {
"taskNotEditable": "すでに文字起こし作業着手中またはタスクが存在しないため、タイピストを変更できません。"
},
"label": {
"title": "Dictations",
"displayInfomation": "Display Information",
@ -209,7 +213,10 @@
"playback": "Playback",
"fileProperty": "File Property",
"changeTranscriptionist": "Change Transcriptionist",
"deleteDictation": "Delete Dictation"
"deleteDictation": "Delete Dictation",
"selectedTranscriptionist": "Selected",
"poolTranscriptionist": "Pool",
"saveChanges": "Save changes"
}
}
}

View File

@ -5,7 +5,8 @@
"passwordIncorrectError": "(es)Error Message",
"emailIncorrectError": "(es)Error Message",
"internalServerError": "(es)処理に失敗しました。時間をおいて再実行しても解決しない場合はシステム管理者にお問い合わせください。",
"listEmpty": "(es)検索結果が0件です。"
"listEmpty": "(es)検索結果が0件です。",
"dialogConfirm": "(es)操作を実行しますか?"
},
"label": {
"cancel": "(es)Cancel",
@ -171,6 +172,9 @@
}
},
"dictationPage": {
"message": {
"taskNotEditable": "(es)すでに文字起こし作業着手中またはタスクが存在しないため、タイピストを変更できません。"
},
"label": {
"title": "(es)Dictations",
"displayInfomation": "(es)Display Information",
@ -209,7 +213,10 @@
"playback": "(es)Playback",
"fileProperty": "(es)File Property",
"changeTranscriptionist": "(es)Change Transcriptionist",
"deleteDictation": "(es)Delete Dictation"
"deleteDictation": "(es)Delete Dictation",
"selectedTranscriptionist": "(es)Selected",
"poolTranscriptionist": "(es)Pool",
"saveChanges": "(es)Save changes"
}
}
}

View File

@ -5,7 +5,8 @@
"passwordIncorrectError": "(fr)Error Message",
"emailIncorrectError": "(fr)Error Message",
"internalServerError": "(fr)処理に失敗しました。時間をおいて再実行しても解決しない場合はシステム管理者にお問い合わせください。",
"listEmpty": "(fr)検索結果が0件です。"
"listEmpty": "(fr)検索結果が0件です。",
"dialogConfirm": "(fr)操作を実行しますか?"
},
"label": {
"cancel": "(fr)Cancel",
@ -171,6 +172,9 @@
}
},
"dictationPage": {
"message": {
"taskNotEditable": "(fr)すでに文字起こし作業着手中またはタスクが存在しないため、タイピストを変更できません。"
},
"label": {
"title": "(fr)Dictations",
"displayInfomation": "(fr)Display Information",
@ -209,7 +213,10 @@
"playback": "(fr)Playback",
"fileProperty": "(fr)File Property",
"changeTranscriptionist": "(fr)Change Transcriptionist",
"deleteDictation": "(fr)Delete Dictation"
"deleteDictation": "(fr)Delete Dictation",
"selectedTranscriptionist": "(fr)Selected",
"poolTranscriptionist": "(fr)Pool",
"saveChanges": "(fr)Save changes"
}
}
}