diff --git a/.env b/.env index b2baf36..43a16b8 100644 --- a/.env +++ b/.env @@ -5,4 +5,5 @@ DB_NAME=omds DB_ROOT_PASS=omdsdbpass DB_USERNAME=omdsdbuser DB_PASSWORD=omdsdbpass +REDIS_PASSWORD=omdsredispass NO_COLOR=TRUE \ No newline at end of file diff --git a/azure-pipelines-production.yml b/azure-pipelines-production.yml index b35783a..45eba43 100644 --- a/azure-pipelines-production.yml +++ b/azure-pipelines-production.yml @@ -1,3 +1,6 @@ +# Pipeline側でKeyVaultやDocker、AppService等に対する操作権限を持ったServiceConenctionを作成し、 +# 環境変数 AZURE_SERVICE_CONNECTION の値としてServiceConenction名を設定しておくこと +# また、環境変数 STATIC_DICTATION_DEPLOYMENT_TOKEN の値として静的WebAppsのデプロイトークンを設定しておくこと trigger: tags: include: @@ -22,36 +25,98 @@ jobs: exit 1 fi displayName: 'タグが付けられたCommitがmainブランチに存在するか確認' -- job: backend_build - dependsOn: initialize - condition: succeeded('initialize') - displayName: Dictation App Service Deploy +- job: backend_deploy + displayName: Backend Deploy pool: - vmImage: ubuntu-latest + name: odms-deploy-pipeline steps: - checkout: self clean: true fetchDepth: 1 -- job: frontend_build - dependsOn: initialize - condition: succeeded('initialize') - displayName: Dictation Static App Service Deploy + - task: AzureRmWebAppDeployment@4 + inputs: + ConnectionType: 'AzureRM' + azureSubscription: $(AZURE_SERVICE_CONNECTION) + appType: 'webAppContainer' + WebAppName: 'app-odms-dictation-prod' + ResourceGroupName: 'prod-application-rg' + DockerNamespace: 'crodmsregistrymaintenance.azurecr.io' + DockerRepository: '$(Build.Repository.Name)/staging/dictation' + DockerImageTag: '$(Build.SourceVersion)' +- job: frontend_deploy + displayName: Deploy Frontend Files + variables: + storageAccountName: saomdspipeline + containerName: staging pool: - vmImage: ubuntu-latest + name: odms-deploy-pipeline steps: - checkout: self clean: true fetchDepth: 1 + - task: AzureKeyVault@2 + displayName: 'Azure Key Vault: kv-odms-secret-prod' + inputs: + ConnectedServiceName: $(AZURE_SERVICE_CONNECTION) + KeyVaultName: kv-odms-secret-prod + SecretsFilter: '*' + - task: AzureCLI@2 + inputs: + azureSubscription: $(AZURE_SERVICE_CONNECTION) + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + az storage blob download \ + --auth-mode login \ + --account-name $(storageAccountName) \ + --container-name $(containerName) \ + --name $(Build.SourceVersion).zip \ + --file $(Build.SourcesDirectory)/$(Build.SourceVersion).zip + - task: Bash@3 + displayName: Bash Script + inputs: + targetType: inline + script: unzip $(Build.SourcesDirectory)/$(Build.SourceVersion).zip -d $(Build.SourcesDirectory)/$(Build.SourceVersion) + - task: AzureStaticWebApp@0 + displayName: 'Static Web App: ' + inputs: + workingDirectory: '$(Build.SourcesDirectory)' + app_location: '/$(Build.SourceVersion)' + config_file_location: /dictation_client + skip_app_build: true + skip_api_build: true + is_static_export: false + verbose: false + azure_static_web_apps_api_token: $(STATIC_DICTATION_DEPLOYMENT_TOKEN) - job: migration condition: succeeded('initialize') displayName: DB migration dependsOn: - initialize - - backend_build - - frontend_build + - backend_deploy + - frontend_deploy pool: name: db-migrate-pipelines steps: - checkout: self clean: true fetchDepth: 1 + - task: AzureKeyVault@2 + displayName: 'Azure Key Vault: kv-odms-secret-prod' + inputs: + ConnectedServiceName: $(AZURE_SERVICE_CONNECTION) + KeyVaultName: kv-odms-secret-prod + - task: CmdLine@2 + displayName: migration + inputs: + script: >2 + # DB接続情報書き換え + sed -i -e "s/DB_NAME/$(db-name)/g" ./dictation_server/db/dbconfig.yml + sed -i -e "s/DB_PASS/$(db-pass)/g" ./dictation_server/db/dbconfig.yml + sed -i -e "s/DB_USERNAME/$(db-user)/g" ./dictation_server/db/dbconfig.yml + sed -i -e "s/DB_PORT/$(db-port)/g" ./dictation_server/db/dbconfig.yml + sed -i -e "s/DB_HOST/$(db-host)/g" ./dictation_server/db/dbconfig.yml + sql-migrate --version + cat ./dictation_server/db/dbconfig.yml + # migration実行 + sql-migrate up -config=./dictation_server/db/dbconfig.yml -env=ci \ No newline at end of file diff --git a/azure-pipelines-staging.yml b/azure-pipelines-staging.yml index ba3c32c..78965ab 100644 --- a/azure-pipelines-staging.yml +++ b/azure-pipelines-staging.yml @@ -1,3 +1,6 @@ +# Pipeline側でKeyVaultやDocker、AppService等に対する操作権限を持ったServiceConenctionを作成し、 +# 環境変数 AZURE_SERVICE_CONNECTION の値としてServiceConenction名を設定しておくこと +# また、環境変数 STATIC_DICTATION_DEPLOYMENT_TOKEN の値として静的WebAppsのデプロイトークンを設定しておくこと trigger: branches: include: @@ -28,33 +31,203 @@ jobs: - job: backend_build dependsOn: initialize condition: succeeded('initialize') - displayName: Dictation App Service Deploy + displayName: Build And Push Backend Image pool: - vmImage: ubuntu-latest + name: odms-deploy-pipeline steps: - checkout: self clean: true fetchDepth: 1 + - task: Npm@1 + displayName: npm ci + inputs: + command: ci + workingDir: dictation_server + verbose: false + - task: AzureKeyVault@2 + displayName: 'Azure Key Vault: kv-odms-secret-stg' + inputs: + ConnectedServiceName: $(AZURE_SERVICE_CONNECTION) + KeyVaultName: kv-odms-secret-stg + SecretsFilter: '*' + - task: Bash@3 + displayName: Bash Script (Test) + inputs: + targetType: inline + script: | + cd dictation_server + npm run test + env: + JWT_PUBLIC_KEY: $(token-public-key) + SENDGRID_API_KEY: $(sendgrid-api-key) + NOTIFICATION_HUB_NAME: $(notification-hub-name) + NOTIFICATION_HUB_CONNECT_STRING: $(notification-hub-connect-string) + STORAGE_ACCOUNT_NAME_US: $(storage-account-name-us) + STORAGE_ACCOUNT_NAME_AU: $(storage-account-name-au) + STORAGE_ACCOUNT_NAME_EU: $(storage-account-name-eu) + STORAGE_ACCOUNT_KEY_US: $(storage-account-key-us) + STORAGE_ACCOUNT_KEY_AU: $(storage-account-key-au) + STORAGE_ACCOUNT_KEY_EU: $(storage-account-key-eu) + STORAGE_ACCOUNT_ENDPOINT_US: $(storage-account-endpoint-us) + STORAGE_ACCOUNT_ENDPOINT_AU: $(storage-account-endpoint-au) + STORAGE_ACCOUNT_ENDPOINT_EU: $(storage-account-endpoint-eu) + ADB2C_TENANT_ID: $(adb2c-tenant-id) + ADB2C_CLIENT_ID: $(adb2c-client-id) + ADB2C_CLIENT_SECRET: $(adb2c-client-secret) + - task: Docker@0 + displayName: build + inputs: + azureSubscriptionEndpoint: $(AZURE_SERVICE_CONNECTION) + azureContainerRegistry: '{"loginServer":"crodmsregistrymaintenance.azurecr.io", "id" : "/subscriptions/108fb131-cdca-4729-a2be-e5bd8c0b3ba7/resourceGroups/maintenance-rg/providers/Microsoft.ContainerRegistry/registries/crOdmsRegistryMaintenance"}' + dockerFile: DockerfileServerDictation.dockerfile + imageName: $(Build.Repository.Name)/staging/dictation:$(Build.SourceVersion) + - task: Docker@0 + displayName: push + inputs: + azureSubscriptionEndpoint: $(AZURE_SERVICE_CONNECTION) + azureContainerRegistry: '{"loginServer":"crodmsregistrymaintenance.azurecr.io", "id" : "/subscriptions/108fb131-cdca-4729-a2be-e5bd8c0b3ba7/resourceGroups/maintenance-rg/providers/Microsoft.ContainerRegistry/registries/crOdmsRegistryMaintenance"}' + action: Push an image + imageName: $(Build.Repository.Name)/staging/dictation:$(Build.SourceVersion) +- job: backend_deploy + dependsOn: backend_build + condition: succeeded('backend_build') + displayName: Backend Deploy + pool: + name: odms-deploy-pipeline + steps: + - checkout: self + clean: true + fetchDepth: 1 + - task: AzureRmWebAppDeployment@4 + inputs: + ConnectionType: 'AzureRM' + azureSubscription: $(AZURE_SERVICE_CONNECTION) + appType: 'webAppContainer' + WebAppName: 'app-odms-dictation-stg' + ResourceGroupName: 'stg-application-rg' + DockerNamespace: 'crodmsregistrymaintenance.azurecr.io' + DockerRepository: '$(Build.Repository.Name)/staging/dictation' + DockerImageTag: '$(Build.SourceVersion)' - job: frontend_build dependsOn: initialize condition: succeeded('initialize') - displayName: Dictation Static App Service Deploy + displayName: Build Frontend Files + variables: + storageAccountName: saomdspipeline + containerName: staging pool: - vmImage: ubuntu-latest + name: odms-deploy-pipeline steps: - checkout: self clean: true fetchDepth: 1 + - task: Npm@1 + displayName: npm ci + inputs: + command: ci + workingDir: dictation_client + verbose: false + - task: Bash@3 + displayName: Bash Script + inputs: + targetType: inline + script: cd dictation_client && npm run build + - task: ArchiveFiles@2 + inputs: + rootFolderOrFile: dictation_client/build + includeRootFolder: false + archiveType: 'zip' + archiveFile: '$(Build.ArtifactStagingDirectory)/$(Build.SourceVersion).zip' + replaceExistingArchive: true + - task: AzureCLI@2 + inputs: + azureSubscription: $(AZURE_SERVICE_CONNECTION) + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + az storage blob upload \ + --auth-mode login \ + --account-name $(storageAccountName) \ + --container-name $(containerName) \ + --name $(Build.SourceVersion).zip \ + --type block \ + --file $(Build.ArtifactStagingDirectory)/$(Build.SourceVersion).zip +- job: frontend_deploy + dependsOn: frontend_build + condition: succeeded('frontend_build') + displayName: Deploy Frontend Files + variables: + storageAccountName: saomdspipeline + containerName: staging + pool: + name: odms-deploy-pipeline + steps: + - checkout: self + clean: true + fetchDepth: 1 + - task: AzureKeyVault@2 + displayName: 'Azure Key Vault: kv-odms-secret-stg' + inputs: + ConnectedServiceName: $(AZURE_SERVICE_CONNECTION) + KeyVaultName: kv-odms-secret-stg + SecretsFilter: '*' + - task: AzureCLI@2 + inputs: + azureSubscription: $(AZURE_SERVICE_CONNECTION) + scriptType: 'bash' + scriptLocation: 'inlineScript' + inlineScript: | + az storage blob download \ + --auth-mode login \ + --account-name $(storageAccountName) \ + --container-name $(containerName) \ + --name $(Build.SourceVersion).zip \ + --file $(Build.SourcesDirectory)/$(Build.SourceVersion).zip + - task: Bash@3 + displayName: Bash Script + inputs: + targetType: inline + script: unzip $(Build.SourcesDirectory)/$(Build.SourceVersion).zip -d $(Build.SourcesDirectory)/$(Build.SourceVersion) + - task: AzureStaticWebApp@0 + displayName: 'Static Web App: ' + inputs: + workingDirectory: '$(Build.SourcesDirectory)' + app_location: '/$(Build.SourceVersion)' + config_file_location: /dictation_client + skip_app_build: true + skip_api_build: true + is_static_export: false + verbose: false + azure_static_web_apps_api_token: $(STATIC_DICTATION_DEPLOYMENT_TOKEN) - job: migration condition: succeeded('initialize') displayName: DB migration dependsOn: - initialize - - backend_build - - frontend_build + - backend_deploy + - frontend_deploy pool: name: db-migrate-pipelines steps: - checkout: self clean: true - fetchDepth: 1 \ No newline at end of file + fetchDepth: 1 + - task: AzureKeyVault@2 + displayName: 'Azure Key Vault: kv-odms-secret-stg' + inputs: + ConnectedServiceName: $(AZURE_SERVICE_CONNECTION) + KeyVaultName: kv-odms-secret-stg + - task: CmdLine@2 + displayName: migration + inputs: + script: >2 + # DB接続情報書き換え + sed -i -e "s/DB_NAME/$(db-name)/g" ./dictation_server/db/dbconfig.yml + sed -i -e "s/DB_PASS/$(db-pass)/g" ./dictation_server/db/dbconfig.yml + sed -i -e "s/DB_USERNAME/$(db-user)/g" ./dictation_server/db/dbconfig.yml + sed -i -e "s/DB_PORT/$(db-port)/g" ./dictation_server/db/dbconfig.yml + sed -i -e "s/DB_HOST/$(db-host)/g" ./dictation_server/db/dbconfig.yml + sql-migrate --version + cat ./dictation_server/db/dbconfig.yml + # migration実行 + sql-migrate up -config=./dictation_server/db/dbconfig.yml -env=ci \ No newline at end of file diff --git a/dictation_client/src/AppRouter.tsx b/dictation_client/src/AppRouter.tsx index 08b7185..248c80b 100644 --- a/dictation_client/src/AppRouter.tsx +++ b/dictation_client/src/AppRouter.tsx @@ -18,6 +18,7 @@ import DictationPage from "pages/DictationPage"; import PartnerPage from "pages/PartnerPage"; import WorkflowPage from "pages/WorkflowPage"; import TypistGroupSettingPage from "pages/TypistGroupSettingPage"; +import WorktypeIdSettingPage from "pages/WorkTypeIdSettingPage"; const AppRouter: React.FC = () => ( @@ -66,6 +67,10 @@ const AppRouter: React.FC = () => ( path="/workflow/typist-group" element={} />} /> + } />} + /> } />} diff --git a/dictation_client/src/api/api.ts b/dictation_client/src/api/api.ts index e0430ca..429eb58 100644 --- a/dictation_client/src/api/api.ts +++ b/dictation_client/src/api/api.ts @@ -462,6 +462,25 @@ export interface CreateTypistGroupRequest { */ 'typistIds': Array; } +/** + * + * @export + * @interface CreateWorktypesRequest + */ +export interface CreateWorktypesRequest { + /** + * WorktypeID + * @type {string} + * @memberof CreateWorktypesRequest + */ + 'worktypeId': string; + /** + * Worktypeの説明 + * @type {string} + * @memberof CreateWorktypesRequest + */ + 'description'?: string; +} /** * * @export @@ -873,15 +892,15 @@ export interface GetUsersResponse { /** * * @export - * @interface GetWorkTypesResponse + * @interface GetWorktypesResponse */ -export interface GetWorkTypesResponse { +export interface GetWorktypesResponse { /** * - * @type {Array} - * @memberof GetWorkTypesResponse + * @type {Array} + * @memberof GetWorktypesResponse */ - 'workTypes': Array; + 'worktypes': Array; } /** * @@ -1609,25 +1628,25 @@ export interface User { /** * * @export - * @interface WorkType + * @interface Worktype */ -export interface WorkType { +export interface Worktype { /** - * WorkTypeのID + * WorktypeのID * @type {number} - * @memberof WorkType + * @memberof Worktype */ 'id': number; /** - * WorkTypeID + * WorktypeID * @type {string} - * @memberof WorkType + * @memberof Worktype */ - 'workTypeId': string; + 'worktypeId': string; /** - * WorkTypeの説明 + * Worktypeの説明 * @type {string} - * @memberof WorkType + * @memberof Worktype */ 'description'?: string; } @@ -1794,6 +1813,46 @@ export const AccountsApiAxiosParamCreator = function (configuration?: Configurat options: localVarRequestOptions, }; }, + /** + * + * @summary + * @param {CreateWorktypesRequest} createWorktypesRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createWorktype: async (createWorktypesRequest: CreateWorktypesRequest, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'createWorktypesRequest' is not null or undefined + assertParamExists('createWorktype', 'createWorktypesRequest', createWorktypesRequest) + const localVarPath = `/accounts/worktypes`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(createWorktypesRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @summary @@ -2256,6 +2315,17 @@ export const AccountsApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.createTypistGroup(createTypistGroupRequest, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @summary + * @param {CreateWorktypesRequest} createWorktypesRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createWorktype(createWorktypesRequest: CreateWorktypesRequest, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createWorktype(createWorktypesRequest, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @summary @@ -2346,7 +2416,7 @@ export const AccountsApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getWorktypes(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async getWorktypes(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.getWorktypes(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -2423,6 +2493,16 @@ export const AccountsApiFactory = function (configuration?: Configuration, baseP createTypistGroup(createTypistGroupRequest: CreateTypistGroupRequest, options?: any): AxiosPromise { return localVarFp.createTypistGroup(createTypistGroupRequest, options).then((request) => request(axios, basePath)); }, + /** + * + * @summary + * @param {CreateWorktypesRequest} createWorktypesRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createWorktype(createWorktypesRequest: CreateWorktypesRequest, options?: any): AxiosPromise { + return localVarFp.createWorktype(createWorktypesRequest, options).then((request) => request(axios, basePath)); + }, /** * * @summary @@ -2505,7 +2585,7 @@ export const AccountsApiFactory = function (configuration?: Configuration, baseP * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getWorktypes(options?: any): AxiosPromise { + getWorktypes(options?: any): AxiosPromise { return localVarFp.getWorktypes(options).then((request) => request(axios, basePath)); }, /** @@ -2587,6 +2667,18 @@ export class AccountsApi extends BaseAPI { return AccountsApiFp(this.configuration).createTypistGroup(createTypistGroupRequest, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @summary + * @param {CreateWorktypesRequest} createWorktypesRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AccountsApi + */ + public createWorktype(createWorktypesRequest: CreateWorktypesRequest, options?: AxiosRequestConfig) { + return AccountsApiFp(this.configuration).createWorktype(createWorktypesRequest, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @summary diff --git a/dictation_client/src/app/store.ts b/dictation_client/src/app/store.ts index 722801d..73c16f7 100644 --- a/dictation_client/src/app/store.ts +++ b/dictation_client/src/app/store.ts @@ -14,6 +14,7 @@ import dictation from "features/dictation/dictationSlice"; import partner from "features/partner/partnerSlice"; import licenseOrderHistory from "features/license/licenseOrderHistory/licenseOrderHistorySlice"; import typistGroup from "features/workflow/typistGroup/typistGroupSlice"; +import worktype from "features/workflow/worktype/worktypeSlice"; export const store = configureStore({ reducer: { @@ -32,6 +33,7 @@ export const store = configureStore({ dictation, partner, typistGroup, + worktype, }, }); diff --git a/dictation_client/src/assets/images/worktype_add.svg b/dictation_client/src/assets/images/worktype_add.svg new file mode 100644 index 0000000..a63ac00 --- /dev/null +++ b/dictation_client/src/assets/images/worktype_add.svg @@ -0,0 +1,12 @@ + + + + + + diff --git a/dictation_client/src/common/errors/code.ts b/dictation_client/src/common/errors/code.ts index 241d0be..d8eb794 100644 --- a/dictation_client/src/common/errors/code.ts +++ b/dictation_client/src/common/errors/code.ts @@ -51,4 +51,6 @@ export const errorCodes = [ "E010809", // ライセンス発行キャンセル不可エラー(ステータスが変えられている場合) "E010810", // ライセンス発行キャンセル不可エラー(発行から一定期間経過した場合) "E010811", // ライセンス発行キャンセル不可エラー(発行したライセンスが割り当てされている場合) + "E011001", // ワークタイプ重複エラー + "E011002", // ワークタイプ登録上限超過エラー ] as const; diff --git a/dictation_client/src/features/workflow/worktype/index.ts b/dictation_client/src/features/workflow/worktype/index.ts new file mode 100644 index 0000000..22036b3 --- /dev/null +++ b/dictation_client/src/features/workflow/worktype/index.ts @@ -0,0 +1,4 @@ +export * from "./worktypeSlice"; +export * from "./state"; +export * from "./selectors"; +export * from "./operations"; diff --git a/dictation_client/src/features/workflow/worktype/operations.ts b/dictation_client/src/features/workflow/worktype/operations.ts new file mode 100644 index 0000000..5f754c9 --- /dev/null +++ b/dictation_client/src/features/workflow/worktype/operations.ts @@ -0,0 +1,112 @@ +import { createAsyncThunk } from "@reduxjs/toolkit"; +import type { RootState } from "app/store"; +import { openSnackbar } from "features/ui/uiSlice"; +import { getTranslationID } from "translation"; +import { AccountsApi, GetWorktypesResponse } from "../../../api/api"; +import { Configuration } from "../../../api/configuration"; +import { ErrorObject, createErrorObject } from "../../../common/errors"; + +export const listWorktypesAsync = createAsyncThunk< + GetWorktypesResponse, + void, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("workflow/listWorktypesAsync", 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 worktypes = ( + await accountsApi.getWorktypes({ + headers: { authorization: `Bearer ${accessToken}` }, + }) + ).data; + + return worktypes; + } catch (e) { + // e ⇒ errorObjectに変換" + const error = createErrorObject(e); + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: getTranslationID("common.message.internalServerError"), + }) + ); + return thunkApi.rejectWithValue({ error }); + } +}); + +export const addWorktypeAsync = createAsyncThunk< + { + // return empty + }, + void, + { + // rejectした時の返却値の型 + rejectValue: { + error: ErrorObject; + }; + } +>("workflow/addWorktypeAsync", 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); + // stateからworktypeIdとdescriptionを取得する + const { worktypeId, description } = state.worktype.apps; + + try { + await accountsApi.createWorktype( + { + worktypeId, + description, + }, + { + headers: { authorization: `Bearer ${accessToken}` }, + } + ); + thunkApi.dispatch( + openSnackbar({ + level: "info", + message: getTranslationID("common.message.success"), + }) + ); + + return {}; + } catch (e) { + // e ⇒ errorObjectに変換" + const error = createErrorObject(e); + + let errorMessage = getTranslationID("common.message.internalServerError"); + + // 既に同じworktypeIdが存在する場合 + if (error.code === "E011001") { + errorMessage = getTranslationID( + "worktypeIdSetting.message.alreadyWorktypeIdExistError" + ); + } + // worktypeIdが上限に達している場合 + if (error.code === "E011002") { + errorMessage = getTranslationID( + "worktypeIdSetting.message.worktypeIDLimitError" + ); + } + thunkApi.dispatch( + openSnackbar({ + level: "error", + message: errorMessage, + }) + ); + return thunkApi.rejectWithValue({ error }); + } +}); diff --git a/dictation_client/src/features/workflow/worktype/selectors.ts b/dictation_client/src/features/workflow/worktype/selectors.ts new file mode 100644 index 0000000..e1ec412 --- /dev/null +++ b/dictation_client/src/features/workflow/worktype/selectors.ts @@ -0,0 +1,25 @@ +import { RootState } from "app/store"; + +export const selectWorktypes = (state: RootState) => + state.worktype.domain.worktypes; +export const selectIsLoading = (state: RootState) => + state.worktype.apps.isLoading; + +export const selectWorktypeId = (state: RootState) => + state.worktype.apps.worktypeId; + +export const selectDescription = (state: RootState) => + state.worktype.apps.description; + +// worktypeIdの値をチェックする +export const selectHasErrorWorktypeId = (state: RootState) => { + const { worktypeId } = state.worktype.apps; + // worktypeIdが空文字の場合はエラー + const isEmptyWorktypeId = worktypeId === ""; + + // worktypeIdに\/ : * ? “< > | .が含まれている場合はエラー + const incorrectPattern = /[\\/:*?"<>|.]|[^ -~]/; + const hasIncorrectPatternWorktypeId = incorrectPattern.test(worktypeId); + + return { isEmptyWorktypeId, hasIncorrectPatternWorktypeId }; +}; diff --git a/dictation_client/src/features/workflow/worktype/state.ts b/dictation_client/src/features/workflow/worktype/state.ts new file mode 100644 index 0000000..2e101f7 --- /dev/null +++ b/dictation_client/src/features/workflow/worktype/state.ts @@ -0,0 +1,16 @@ +import { Worktype } from "api"; + +export interface WorktypeState { + apps: Apps; + domain: Domain; +} + +export interface Apps { + isLoading: boolean; + worktypeId: string; + description?: string; +} + +export interface Domain { + worktypes?: Worktype[]; +} diff --git a/dictation_client/src/features/workflow/worktype/worktypeSlice.ts b/dictation_client/src/features/workflow/worktype/worktypeSlice.ts new file mode 100644 index 0000000..d0345e5 --- /dev/null +++ b/dictation_client/src/features/workflow/worktype/worktypeSlice.ts @@ -0,0 +1,58 @@ +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; +import { WorktypeState } from "./state"; +import { addWorktypeAsync, listWorktypesAsync } from "./operations"; + +const initialState: WorktypeState = { + apps: { + isLoading: false, + worktypeId: "", + }, + domain: {}, +}; + +export const worktypeSlice = createSlice({ + name: "worktype", + initialState, + reducers: { + cleanupWorktype: (state) => { + state.apps.worktypeId = initialState.apps.worktypeId; + state.apps.description = undefined; + }, + changeWorktypeId: ( + state, + action: PayloadAction<{ worktypeId: string }> + ) => { + const { worktypeId } = action.payload; + state.apps.worktypeId = worktypeId; + }, + changeDescription: ( + state, + action: PayloadAction<{ description?: string }> + ) => { + const { description } = action.payload; + state.apps.description = description; + }, + }, + extraReducers: (builder) => { + builder.addCase(listWorktypesAsync.pending, (state) => { + state.apps.isLoading = true; + }); + builder.addCase(listWorktypesAsync.fulfilled, (state, action) => { + const { worktypes } = action.payload; + state.domain.worktypes = worktypes; + state.apps.isLoading = false; + }); + builder.addCase(listWorktypesAsync.rejected, (state) => { + state.apps.isLoading = false; + }); + builder.addCase(addWorktypeAsync.pending, (state) => { + state.apps.isLoading = true; + }); + builder.addCase(addWorktypeAsync.rejected, (state) => { + state.apps.isLoading = false; + }); + }, +}); +export const { changeDescription, changeWorktypeId, cleanupWorktype } = + worktypeSlice.actions; +export default worktypeSlice.reducer; diff --git a/dictation_client/src/pages/DictationPage/changeTranscriptionistPopup.tsx b/dictation_client/src/pages/DictationPage/changeTranscriptionistPopup.tsx index e13e8dd..98d6b38 100644 --- a/dictation_client/src/pages/DictationPage/changeTranscriptionistPopup.tsx +++ b/dictation_client/src/pages/DictationPage/changeTranscriptionistPopup.tsx @@ -185,7 +185,7 @@ export const ChangeTranscriptionistPopup: React.FC< { const isAdmin = isAdminUser(); const isAuthor = isAuthorUser(); const isTypist = isTypistUser(); + const isNone = !isAuthor && !isTypist; + // popup制御関係 const [ isChangeTranscriptionistPopupOpen, @@ -1001,7 +1003,10 @@ const DictationPage: React.FC = (): JSX.Element => { diff --git a/dictation_client/src/pages/LicensePage/licenseSummary.tsx b/dictation_client/src/pages/LicensePage/licenseSummary.tsx index 202e968..1744105 100644 --- a/dictation_client/src/pages/LicensePage/licenseSummary.tsx +++ b/dictation_client/src/pages/LicensePage/licenseSummary.tsx @@ -126,11 +126,7 @@ export const LicenseSummary: React.FC = ( alt="" className={styles.menuIcon} /> - {t( - getTranslationID( - "partnerLicense.label.returnButton" - ) - )} + {t(getTranslationID("common.label.return"))} )} diff --git a/dictation_client/src/pages/LicensePage/partnerLicense.tsx b/dictation_client/src/pages/LicensePage/partnerLicense.tsx index b6653da..c93501d 100644 --- a/dictation_client/src/pages/LicensePage/partnerLicense.tsx +++ b/dictation_client/src/pages/LicensePage/partnerLicense.tsx @@ -277,9 +277,7 @@ const PartnerLicense: React.FC = (): JSX.Element => { alt="" className={styles.menuIcon} /> - {t( - getTranslationID("partnerLicense.label.returnButton") - )} + {t(getTranslationID("common.label.return"))} )} diff --git a/dictation_client/src/pages/TypistGroupSettingPage/addTypistGroupPopup.tsx b/dictation_client/src/pages/TypistGroupSettingPage/addTypistGroupPopup.tsx index 484522b..12000af 100644 --- a/dictation_client/src/pages/TypistGroupSettingPage/addTypistGroupPopup.tsx +++ b/dictation_client/src/pages/TypistGroupSettingPage/addTypistGroupPopup.tsx @@ -190,7 +190,7 @@ export const AddTypistGroupPopup: React.FC = ( diff --git a/dictation_client/src/pages/TypistGroupSettingPage/editTypistGroupPopup.tsx b/dictation_client/src/pages/TypistGroupSettingPage/editTypistGroupPopup.tsx index a20c6c7..faf85d4 100644 --- a/dictation_client/src/pages/TypistGroupSettingPage/editTypistGroupPopup.tsx +++ b/dictation_client/src/pages/TypistGroupSettingPage/editTypistGroupPopup.tsx @@ -184,7 +184,7 @@ export const EditTypistGroupPopup: React.FC = ( diff --git a/dictation_client/src/pages/TypistGroupSettingPage/index.tsx b/dictation_client/src/pages/TypistGroupSettingPage/index.tsx index 2d17ff1..6b1ff06 100644 --- a/dictation_client/src/pages/TypistGroupSettingPage/index.tsx +++ b/dictation_client/src/pages/TypistGroupSettingPage/index.tsx @@ -83,7 +83,7 @@ const TypistGroupSettingPage: React.FC = (): JSX.Element => { className={`${styles.menuLink} ${styles.isActive}`} > - {t(getTranslationID("typistGroupSetting.label.return"))} + {t(getTranslationID("common.label.return"))}
  • @@ -127,11 +127,7 @@ const TypistGroupSettingPage: React.FC = (): JSX.Element => { onEditPopupOpen(group.id); }} > - {t( - getTranslationID( - "typistGroupSetting.label.edit" - ) - )} + {t(getTranslationID("common.label.edit"))}
  • diff --git a/dictation_client/src/pages/WorkTypeIdSettingPage/addWorktypeIdPopup.tsx b/dictation_client/src/pages/WorkTypeIdSettingPage/addWorktypeIdPopup.tsx new file mode 100644 index 0000000..e611f9f --- /dev/null +++ b/dictation_client/src/pages/WorkTypeIdSettingPage/addWorktypeIdPopup.tsx @@ -0,0 +1,144 @@ +import React, { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import styles from "styles/app.module.scss"; +import { useDispatch, useSelector } from "react-redux"; +import { + addWorktypeAsync, + changeDescription, + changeWorktypeId, + cleanupWorktype, + listWorktypesAsync, + selectDescription, + selectHasErrorWorktypeId, + selectWorktypeId, +} from "features/workflow/worktype"; +import { AppDispatch } from "app/store"; +import { getTranslationID } from "translation"; +import close from "../../assets/images/close.svg"; + +// popupのpropsの型定義 +interface AddWorktypeIdPopupProps { + onClose: () => void; + isOpen: boolean; +} + +export const AddWorktypeIdPopup: React.FC = ( + props: AddWorktypeIdPopupProps +): JSX.Element => { + const { onClose, isOpen } = props; + const [t] = useTranslation(); + const dispatch: AppDispatch = useDispatch(); + const worktypeId = useSelector(selectWorktypeId); + const description = useSelector(selectDescription); + // 追加ボタンを押したかどうか + const [isPushAddButton, setIsPushAddButton] = useState(false); + // WorktypeIdのバリデーションチェック + const { hasIncorrectPatternWorktypeId, isEmptyWorktypeId } = useSelector( + selectHasErrorWorktypeId + ); + + // ×ボタンを押した時の処理 + const closePopup = useCallback(() => { + dispatch(cleanupWorktype()); + setIsPushAddButton(false); + onClose(); + }, [onClose, dispatch]); + + // 追加ボタンを押した時の処理 + const addWorktypeId = useCallback(async () => { + setIsPushAddButton(true); + if (isEmptyWorktypeId || hasIncorrectPatternWorktypeId) { + return; + } + const { meta } = await dispatch(addWorktypeAsync()); + if (meta.requestStatus === "fulfilled") { + dispatch(listWorktypesAsync()); + closePopup(); + } + }, [closePopup, dispatch, hasIncorrectPatternWorktypeId, isEmptyWorktypeId]); + + return ( +
    +
    +

    + {t(getTranslationID("worktypeIdSetting.label.addWorktypeId"))} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */} + close +

    +
    +
    +
    +
    {t(getTranslationID("worktypeIdSetting.label.worktypeId"))}
    +
    + { + dispatch(changeWorktypeId({ worktypeId: e.target.value })); + }} + /> + {isPushAddButton && isEmptyWorktypeId && ( + + {t(getTranslationID("common.message.inputEmptyError"))} + + )} + {isPushAddButton && hasIncorrectPatternWorktypeId && ( + + {t( + getTranslationID( + "worktypeIdSetting.message.worktypeIdIncorrectError" + ) + )} + + )} + + {t(getTranslationID("worktypeIdSetting.label.worktypeIdTerms"))} + +
    +
    + {t( + getTranslationID("worktypeIdSetting.label.descriptionOptional") + )} +
    +
    + { + const description = + e.target.value === "" ? undefined : e.target.value; + dispatch(changeDescription({ description })); + }} + /> +
    +
    + +
    +
    +
    +
    +
    + ); +}; diff --git a/dictation_client/src/pages/WorkTypeIdSettingPage/index.tsx b/dictation_client/src/pages/WorkTypeIdSettingPage/index.tsx new file mode 100644 index 0000000..ef20581 --- /dev/null +++ b/dictation_client/src/pages/WorkTypeIdSettingPage/index.tsx @@ -0,0 +1,194 @@ +import { UpdateTokenTimer } from "components/auth/updateTokenTimer"; +import Footer from "components/footer"; +import Header from "components/header"; +import React, { useEffect, useState } from "react"; +import { getTranslationID } from "translation"; +import styles from "styles/app.module.scss"; +import undo from "assets/images/undo.svg"; +import worktype_add from "assets/images/worktype_add.svg"; +import progress_activit from "assets/images/progress_activit.svg"; +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; +import { + listWorktypesAsync, + selectIsLoading, + selectWorktypes, +} from "features/workflow/worktype"; +import { AppDispatch } from "app/store"; +import { AddWorktypeIdPopup } from "./addWorktypeIdPopup"; + +const WorktypeIdSettingPage: React.FC = (): JSX.Element => { + const dispatch: AppDispatch = useDispatch(); + const [t] = useTranslation(); + const isLoading = useSelector(selectIsLoading); + const worktypes = useSelector(selectWorktypes); + const [selectedRow, setSelectedRow] = useState(NaN); + // 追加Popupの表示制御 + const [isShowAddPopup, setIsShowAddPopup] = useState(false); + useEffect(() => { + dispatch(listWorktypesAsync()); + }, [dispatch]); + + return ( + <> + { + setIsShowAddPopup(false); + }} + isOpen={isShowAddPopup} + /> +
    +
    + +
    +
    +
    +

    + {t(getTranslationID("workflowPage.label.title"))} +

    +

    + {t(getTranslationID("worktypeIdSetting.label.title"))} +

    +
    +
    +
    + + + + + + + + {worktypes?.map((worktype) => ( + { + setSelectedRow(worktype.id); + }} + onMouseLeave={() => { + setSelectedRow(NaN); + }} + > + + + + + ))} +
    + {t( + getTranslationID("worktypeIdSetting.label.worktypeId") + )} + + {t( + getTranslationID("worktypeIdSetting.label.description") + )} + {/** empty th */}
    {worktype.worktypeId}{worktype.description} + +
    + {!isLoading && worktypes?.length === 0 && ( +

    + {t(getTranslationID("common.message.listEmpty"))} +

    + )} + {isLoading && ( + Loading + )} +
    +
    +
    +
    +
    +
    + + ); +}; + +export default WorktypeIdSettingPage; diff --git a/dictation_client/src/pages/WorkflowPage/index.tsx b/dictation_client/src/pages/WorkflowPage/index.tsx index e817a00..6d57be9 100644 --- a/dictation_client/src/pages/WorkflowPage/index.tsx +++ b/dictation_client/src/pages/WorkflowPage/index.tsx @@ -15,6 +15,11 @@ const WorkflowPage: React.FC = (): JSX.Element => ( Transcriptionist Group Setting + + + Worktype ID Setting + +