├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── src ├── assets │ ├── images │ │ └── ic_loading.gif │ ├── icons │ │ ├── vindr_lab_logo.png │ │ ├── vindr_lab_logo_white.png │ │ ├── ic_arrow_select.svg │ │ ├── ic_collapse.svg │ │ ├── ic_search.svg │ │ ├── ic_menu_search.svg │ │ ├── ic_menu_recent.svg │ │ ├── ic_menu_project.svg │ │ ├── ic_menu_new_project.svg │ │ ├── ic_menu_label.svg │ │ └── vinlab_logo.svg │ └── index.js ├── components │ ├── layout │ │ ├── index.js │ │ ├── header │ │ │ ├── Header.scss │ │ │ └── Header.js │ │ └── leftMenu │ │ │ ├── LeftMenu.js │ │ │ └── LeftMenu.scss │ ├── circularProgress │ │ ├── CircularProgress.scss │ │ └── index.js │ ├── breadcrumb │ │ ├── BreadCrumb.scss │ │ └── BreadCrumb.js │ ├── uploadDatasets │ │ ├── UploadDatasetsAction.js │ │ ├── ImportDataModal.js │ │ └── UploadDatasets.scss │ ├── loading │ │ ├── Loading.js │ │ └── Loading.scss │ ├── popupContext │ │ ├── index.js │ │ └── PopupContext.scss │ ├── labels │ │ ├── LabelsReducer.js │ │ ├── AddEditGroup.js │ │ ├── LabelsAction.js │ │ ├── LabelGroups.js │ │ ├── Labels.scss │ │ ├── EditLabelModal.js │ │ └── NewLabelModal.js │ └── pagination │ │ ├── Pagination.js │ │ └── PaginationTable.scss ├── setupTests.js ├── view │ ├── labelManagement │ │ ├── LabelManagement.scss │ │ └── LabelManagement.js │ ├── rootReducer.js │ ├── studyList │ │ ├── task │ │ │ └── Task.scss │ │ ├── StudyListReducer.js │ │ ├── data │ │ │ ├── Data.scss │ │ │ ├── DeleteStudyModal.js │ │ │ ├── ExportLabelModal.js │ │ │ └── AssignLabelerModal.js │ │ ├── setting │ │ │ └── Setting.scss │ │ ├── StudyListAction.js │ │ ├── StudyList.js │ │ └── StudyList.scss │ ├── project │ │ ├── ProjectReducer.js │ │ ├── ProjectAction.js │ │ ├── Project.scss │ │ ├── CreateProjectModal.js │ │ └── Project.js │ └── system │ │ ├── systemReducer.js │ │ └── systemAction.js ├── utils │ ├── helpers │ │ └── index.js │ ├── localeProvider │ │ └── LocaleProvider.js │ ├── constants │ │ ├── actions.js │ │ └── config.js │ ├── locale │ │ ├── en.json │ │ └── vi.json │ └── service │ │ └── api.js ├── variables.scss ├── configStore.js ├── index.css ├── Routes.js ├── index.js ├── logo.svg ├── App.js ├── serviceWorker.js └── App.scss ├── run.sh ├── nginx.conf ├── .env.local ├── nginx └── nginx.conf ├── docker-compose.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── .gitlab-ci.yml ├── craco.config.js ├── package.json └── README.md /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinbigdata-medical/vindr-lab-dashboard/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinbigdata-medical/vindr-lab-dashboard/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinbigdata-medical/vindr-lab-dashboard/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/assets/images/ic_loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinbigdata-medical/vindr-lab-dashboard/HEAD/src/assets/images/ic_loading.gif -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cd /app 4 | npm run build:prod 5 | cp -r /app/build/* /var/www/html/ 6 | rm -rf /app/* 7 | nginx -g "daemon off;" 8 | -------------------------------------------------------------------------------- /src/assets/icons/vindr_lab_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinbigdata-medical/vindr-lab-dashboard/HEAD/src/assets/icons/vindr_lab_logo.png -------------------------------------------------------------------------------- /src/assets/icons/vindr_lab_logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinbigdata-medical/vindr-lab-dashboard/HEAD/src/assets/icons/vindr_lab_logo_white.png -------------------------------------------------------------------------------- /src/components/layout/index.js: -------------------------------------------------------------------------------- 1 | import Header from './header/Header'; 2 | import LeftMenu from './leftMenu/LeftMenu'; 3 | 4 | export { Header, LeftMenu }; 5 | -------------------------------------------------------------------------------- /src/components/circularProgress/CircularProgress.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables.scss'; 2 | 3 | .checked-ic { 4 | font-size: 32px; 5 | color: $app-primary-color; 6 | } 7 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | server_name _; 3 | listen 80; 4 | location / { 5 | root /var/www/html/; 6 | try_files $uri $uri/ /index.html; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/assets/icons/ic_arrow_select.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/ic_collapse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.env.local: -------------------------------------------------------------------------------- 1 | MEDICAL_VIEWER_URL = "http://localhost:3000/medical-view/viewer" 2 | SERVER_BASE_URL = "https://dev-label.vindr.ai" 3 | OIDC_CLIENT_ID = "vinlab-frontend" 4 | OIDC_AUDIENCE = "vinlab-backend" 5 | OIDC_SCOPE= "profile" 6 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/components/breadcrumb/BreadCrumb.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables.scss'; 2 | 3 | .breadcrumb-list { 4 | font-weight: 400; 5 | font-size: 16px; 6 | .ant-breadcrumb-separator { 7 | color: $defautl-font-color; 8 | } 9 | .breadcrumb-item { 10 | color: $defautl-font-color; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | location / { 4 | root /usr/share/nginx/html; 5 | index index.html index.htm; 6 | try_files $uri $uri/ /index.html; 7 | } 8 | error_page 500 502 503 504 /50x.html; 9 | 10 | location = /50x.html { 11 | root /usr/share/nginx/html; 12 | } 13 | } -------------------------------------------------------------------------------- /src/components/uploadDatasets/UploadDatasetsAction.js: -------------------------------------------------------------------------------- 1 | import api from '../../utils/service/api'; 2 | 3 | export const actionUploadDICOM = (data = {}, cancelToken) => { 4 | return api({ 5 | method: 'post', 6 | url: '/api/studies/upload', 7 | data, 8 | cancelToken: cancelToken, 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | app: 5 | container_name: app 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | volumes: 10 | - '.:/app' 11 | - '/app/node_modules' 12 | ports: 13 | - '3000:3000' 14 | environment: 15 | - NODE_ENV=development -------------------------------------------------------------------------------- /src/view/labelManagement/LabelManagement.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables.scss'; 2 | 3 | .label-management-page { 4 | display: flex; 5 | flex-direction: column; 6 | .page-content { 7 | flex: 1; 8 | padding: 8px; 9 | .col-labels { 10 | flex: 1; 11 | .labels-wrapper { 12 | padding: 8px 25px; 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/helpers/index.js: -------------------------------------------------------------------------------- 1 | const has = Object.prototype.hasOwnProperty; 2 | 3 | export const isDiff = (A, B) => JSON.stringify(A) !== JSON.stringify(B); 4 | 5 | export const isEmpty = (prop) => { 6 | return ( 7 | prop === null || 8 | prop === undefined || 9 | (has.call(prop, 'length') && prop.length === 0) || 10 | (prop.constructor === Object && Object.keys(prop).length === 0) 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/view/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import system from './system/systemReducer'; 3 | import study from './studyList/StudyListReducer'; 4 | import project from './project/ProjectReducer'; 5 | import label from '../components/labels/LabelsReducer'; 6 | 7 | const rootReducer = combineReducers({ 8 | system, 9 | study, 10 | project, 11 | label 12 | }); 13 | 14 | export default rootReducer; 15 | -------------------------------------------------------------------------------- /src/variables.scss: -------------------------------------------------------------------------------- 1 | /* Variables */ 2 | $header-height: 40px; 3 | $left-menu-width: 200px; 4 | $left-menu-collapsed-width: 83px; 5 | $border-box-radius: 10px; 6 | 7 | /* app colors */ 8 | $defautl-font-color: #d1d1d7; 9 | $app-bg-color: #151515; 10 | $app-primary-color: #17b978; 11 | $app-bg-box: #26292e; 12 | $app-bg-page-content: #1e2025; 13 | $app-box-border-color: #53555a; 14 | 15 | $modal-bg-color: #242e4c; 16 | $font-red-color: #f44336; 17 | -------------------------------------------------------------------------------- /src/components/breadcrumb/BreadCrumb.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Breadcrumb } from 'antd'; 3 | import './BreadCrumb.scss'; 4 | 5 | const BreadCrumb = (props) => { 6 | const { breadcrumbList = [] } = props; 7 | return ( 8 | 9 | {breadcrumbList.map((text, idx) => ( 10 | 11 | {text || ''} 12 | 13 | ))} 14 | 15 | ); 16 | }; 17 | 18 | export default React.memo(BreadCrumb); 19 | -------------------------------------------------------------------------------- /src/view/studyList/task/Task.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables.scss'; 2 | 3 | .task-page { 4 | display: flex; 5 | flex-direction: column; 6 | flex: 1; 7 | 8 | .task-container { 9 | .table-wrapper { 10 | .table-content { 11 | .task-archived { 12 | color: $font-red-color; 13 | } 14 | .task-status { 15 | color: $defautl-font-color; 16 | &.doing { 17 | color: #ffa000; 18 | } 19 | &.completed { 20 | color: $app-primary-color; 21 | } 22 | } 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/components/loading/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Spin } from 'antd'; 4 | import './Loading.scss'; 5 | 6 | const Loading = (props) => { 7 | return ( 8 |
13 |
14 | 15 |
16 |
17 | ); 18 | }; 19 | 20 | export default connect( 21 | (state) => ({ isLoading: state.system.isLoading }), 22 | {} 23 | )(Loading); 24 | -------------------------------------------------------------------------------- /src/assets/icons/ic_search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/configStore.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import reducer from './view/rootReducer'; 4 | 5 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 6 | 7 | const logger = (store) => (next) => (action) => { 8 | // console.group(action.type); 9 | // console.info('dispatching', action); 10 | let result = next(action); 11 | // console.log('next state', store.getState()); 12 | // console.groupEnd(); 13 | return result; 14 | }; 15 | 16 | const store = createStore( 17 | reducer, 18 | composeEnhancers(applyMiddleware(thunk, logger)) 19 | ); 20 | 21 | export default store; 22 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Roboto', 'Segoe UI', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif !important; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | ::-webkit-scrollbar { 16 | width: 6px; 17 | height: 6px; 18 | } 19 | 20 | ::-webkit-scrollbar-track { 21 | background: #e0e0e0; 22 | } 23 | 24 | ::-webkit-scrollbar-thumb { 25 | border-radius: 10px; 26 | background: #a0a0a0; 27 | } 28 | -------------------------------------------------------------------------------- /src/view/project/ProjectReducer.js: -------------------------------------------------------------------------------- 1 | import * as actions from '../../utils/constants/actions'; 2 | 3 | const initialState = { 4 | projects: {}, 5 | currentProject: {}, 6 | isFetching: false, 7 | }; 8 | 9 | const project = (state = initialState, action) => { 10 | switch (action.type) { 11 | case actions.FETCH_PROJECTS: 12 | return { ...state, projects: action.payload, isFetching: false }; 13 | case actions.FETCHING_PROJECTS: 14 | return { ...state, isFetching: action.payload }; 15 | case actions.FETCH_PROJECT_DETAIL: 16 | return { ...state, currentProject: action.payload }; 17 | default: 18 | return state; 19 | } 20 | }; 21 | 22 | export default project; 23 | -------------------------------------------------------------------------------- /src/components/popupContext/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button } from 'antd'; 3 | import './PopupContext.scss'; 4 | 5 | const PopupContext = ({ visible, x, y, buttonList = [] }) => 6 | visible && ( 7 | 21 | ); 22 | 23 | export default PopupContext; 24 | -------------------------------------------------------------------------------- /src/Routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Switch, Route } from 'react-router-dom'; 3 | import { routes } from './utils/constants/config'; 4 | import Project from './view/project/Project'; 5 | import StudyList from './view/studyList/StudyList'; 6 | import LabelManagement from './view/labelManagement/LabelManagement'; 7 | 8 | const Routes = (props) => { 9 | return ( 10 | 11 | {/* */} 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default Routes; 20 | -------------------------------------------------------------------------------- /src/assets/index.js: -------------------------------------------------------------------------------- 1 | export { default as ic_loading } from './images/ic_loading.gif'; 2 | 3 | export { ReactComponent as IconSearch } from './icons/ic_search.svg'; 4 | export { ReactComponent as IconMenuNewProject } from './icons/ic_menu_new_project.svg'; 5 | export { ReactComponent as IconMenuProject } from './icons/ic_menu_project.svg'; 6 | export { ReactComponent as IconMenuRecent } from './icons/ic_menu_recent.svg'; 7 | export { ReactComponent as IconMenuSearch } from './icons/ic_menu_search.svg'; 8 | export { ReactComponent as IconMenuLabel } from './icons/ic_menu_label.svg'; 9 | export { ReactComponent as IconCollapse } from './icons/ic_collapse.svg'; 10 | export { ReactComponent as IconArrowSelect } from './icons/ic_arrow_select.svg'; 11 | export { default as vindr_lab_logo_white } from './icons/vindr_lab_logo_white.png'; 12 | -------------------------------------------------------------------------------- /src/components/popupContext/PopupContext.scss: -------------------------------------------------------------------------------- 1 | .popup-context { 2 | background-clip: padding-box; 3 | background-color: #fff; 4 | border-radius: 4px; 5 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); 6 | left: 0px; 7 | list-style-type: none; 8 | margin: 0; 9 | outline: none; 10 | padding: 0; 11 | position: fixed; 12 | text-align: left; 13 | top: 0px; 14 | overflow: hidden; 15 | -webkit-box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); 16 | min-width: 90px; 17 | li { 18 | clear: both; 19 | cursor: pointer; 20 | font-size: 14px; 21 | margin: 0; 22 | white-space: nowrap; 23 | .btn-popup { 24 | width: 100%; 25 | text-align: left; 26 | color: rgba(0, 0, 0, 0.65); 27 | &:hover { 28 | background-color: #e6faef; 29 | } 30 | &[disabled] { 31 | color: rgba(0, 0, 0, 0.3); 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/assets/icons/ic_menu_search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # FROM node:10-alpine as builder 2 | 3 | # # install and cache app dependencies 4 | # COPY package.json ./ 5 | # RUN npm install && mkdir /app && mv ./node_modules ./app 6 | 7 | # WORKDIR /app 8 | 9 | # COPY . . 10 | 11 | # RUN npm run build:prod 12 | 13 | 14 | 15 | # # ------------------------------------------------------ 16 | # # Production Build 17 | # # ------------------------------------------------------ 18 | 19 | # FROM nginx:1.16.0-alpine 20 | # COPY --from=builder /app/build /usr/share/nginx/html 21 | # RUN rm /etc/nginx/conf.d/default.conf 22 | # COPY nginx/nginx.conf /etc/nginx/conf.d 23 | # EXPOSE 80 24 | # CMD ["nginx", "-g", "daemon off;"] 25 | 26 | FROM node:14.14.0-stretch 27 | 28 | RUN apt-get update && apt-get install nginx -y 29 | 30 | WORKDIR /app 31 | 32 | COPY package*.json /app/ 33 | RUN npm install 34 | COPY ./ /app/ 35 | COPY ./nginx.conf /etc/nginx/sites-enabled/default 36 | RUN chmod +x run.sh 37 | 38 | CMD ["./run.sh"] 39 | -------------------------------------------------------------------------------- /src/utils/localeProvider/LocaleProvider.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import moment from 'moment'; 3 | import { IntlProvider } from 'react-intl'; 4 | import { ConfigProvider } from 'antd'; 5 | import { connect } from 'react-redux'; 6 | import vi_VN from 'antd/es/locale/vi_VN'; 7 | import en_US from 'antd/es/locale/en_US'; 8 | import 'moment/locale/vi'; 9 | import vi from '../locale/vi.json'; 10 | import en from '../locale/en.json'; 11 | 12 | const Locales = (props) => { 13 | const localeIntl = { 14 | locale: props.locale, 15 | messages: props.locale === 'en' ? en : vi, 16 | }; 17 | moment.locale(props.locale); 18 | return ( 19 | 20 | 21 | {props.children} 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default connect( 28 | (state) => ({ 29 | locale: state.system.locale, 30 | }), 31 | null 32 | )(Locales); 33 | -------------------------------------------------------------------------------- /src/assets/icons/ic_menu_recent.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/components/circularProgress/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | CircularProgressbarWithChildren, 4 | buildStyles, 5 | } from 'react-circular-progressbar'; 6 | import 'react-circular-progressbar/dist/styles.css'; 7 | import { CheckOutlined } from '@ant-design/icons'; 8 | import './CircularProgress.scss'; 9 | 10 | const CircularProgress = ({ percentage }) => { 11 | return ( 12 | 24 | {percentage === 100 && } 25 | 26 | ); 27 | }; 28 | 29 | export default React.memo(CircularProgress); 30 | -------------------------------------------------------------------------------- /src/components/loading/Loading.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables.scss'; 2 | 3 | .loading-wrapper { 4 | position: fixed; 5 | top: 0; 6 | right: 0; 7 | bottom: 0; 8 | left: 0; 9 | transition: all 0.15s ease-in; 10 | } 11 | 12 | .loading-wrapper.ld-show { 13 | opacity: 1; 14 | z-index: 999; 15 | display: block; 16 | } 17 | 18 | .loading-wrapper.ld-hide { 19 | opacity: 0; 20 | z-index: -1; 21 | display: none; 22 | } 23 | 24 | .ld-dim-light { 25 | background: rgba(255, 255, 255, 1); 26 | } 27 | 28 | .ld-dim-dark { 29 | background: rgba($color: $app-bg-page-content, $alpha: 1); 30 | } 31 | 32 | .middle-sreen { 33 | position: fixed; 34 | top: 50%; 35 | left: 50%; 36 | transform: translate(-50%, -50%); 37 | z-index: 1993; 38 | } 39 | 40 | @-webkit-keyframes spin { 41 | 0% { 42 | -webkit-transform: rotate(0deg); 43 | } 44 | 100% { 45 | -webkit-transform: rotate(360deg); 46 | } 47 | } 48 | 49 | @keyframes spin { 50 | 0% { 51 | transform: rotate(0deg); 52 | } 53 | 100% { 54 | transform: rotate(360deg); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/components/labels/LabelsReducer.js: -------------------------------------------------------------------------------- 1 | import * as actions from '../../utils/constants/actions'; 2 | 3 | const initialState = { 4 | labels: {}, 5 | labelGroups: {}, 6 | isFetchingLabels: false, 7 | isFetchingLabelGroups: false, 8 | selectedLabelGroup: {}, 9 | }; 10 | 11 | const label = (state = initialState, action) => { 12 | switch (action.type) { 13 | case actions.FETCH_LABELS: 14 | return { ...state, labels: action.payload, isFetchingLabels: false }; 15 | case actions.FETCHING_LABELS: 16 | return { ...state, isFetchingLabels: action.payload }; 17 | case actions.FETCH_LABEL_GROUPS: 18 | return { 19 | ...state, 20 | labelGroups: action.payload, 21 | isFetchingLabelGroups: false, 22 | }; 23 | case actions.FETCHING_LABEL_GROUPS: 24 | return { ...state, isFetchingLabelGroups: action.payload }; 25 | case actions.SET_SELECTED_LABEL_GROUPS: 26 | return { ...state, selectedLabelGroup: action.payload }; 27 | default: 28 | return state; 29 | } 30 | }; 31 | 32 | export default label; 33 | -------------------------------------------------------------------------------- /src/view/studyList/StudyListReducer.js: -------------------------------------------------------------------------------- 1 | import * as actions from '../../utils/constants/actions'; 2 | 3 | const initialState = { 4 | studies: {}, 5 | isFetching: false, 6 | exportedVersion: {}, 7 | isFetchingTask: false, 8 | tasks: {}, 9 | totalStatus: {}, 10 | }; 11 | 12 | const study = (state = initialState, action) => { 13 | switch (action.type) { 14 | case actions.FETCH_STUDIES: 15 | return { ...state, studies: action.payload, isFetching: false }; 16 | case actions.FETCHING_STUDIES: 17 | return { ...state, isFetching: action.payload }; 18 | case actions.FETCH_TASKS: 19 | return { ...state, tasks: action.payload, isFetchingTask: false }; 20 | case actions.FETCHING_TASKS: 21 | return { ...state, isFetchingTask: action.payload }; 22 | case actions.FETCH_EXPORTED_VERSIONS: 23 | return { ...state, exportedVersion: action.payload }; 24 | case actions.FETCH_STATS_STUDIES: 25 | return { ...state, totalStatus: action.payload }; 26 | default: 27 | return state; 28 | } 29 | }; 30 | 31 | export default study; 32 | -------------------------------------------------------------------------------- /src/components/pagination/Pagination.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Pagination, Select } from 'antd'; 3 | import './PaginationTable.scss'; 4 | 5 | const pageSizeOptions = [10, 25, 50, 100]; 6 | 7 | const PaginationTable = (props) => { 8 | return ( 9 |
10 | Row: 11 | 22 | 32 |
33 | ); 34 | }; 35 | 36 | export default PaginationTable; 37 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import { Provider } from 'react-redux'; 5 | import store from './configStore'; 6 | import LocaleProvider from './utils/localeProvider/LocaleProvider'; 7 | import { BASE_ROUTER_PREFIX } from './utils/constants/config'; 8 | import './index.css'; 9 | import App from './App'; 10 | import * as serviceWorker from './serviceWorker'; 11 | 12 | window.BUILD_INFO = process.env.BUILD_TIME + '-' + process.env.BUILD_USERNAME; 13 | 14 | ReactDOM.render( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | , 24 | document.getElementById('root') 25 | ); 26 | 27 | // If you want your app to work offline and load faster, you can change 28 | // unregister() to register() below. Note this comes with some pitfalls. 29 | // Learn more about service workers: https://bit.ly/CRA-PWA 30 | serviceWorker.unregister(); 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 VinBigdata Medical 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | .job-build-docker-image: &build-docker-image 2 | image: docker:stable 3 | services: 4 | - docker:dind 5 | variables: 6 | DOCKER_HOST: tcp://docker:2375/ 7 | DOCKER_DRIVER: overlay2 8 | DOCKER_TLS_CERTDIR: "" 9 | before_script: 10 | - apk add python3 py-pip 11 | - echo ${DOCKERHUB_PASSWORD} | docker login --username ${DOCKERHUB_USERNAME} --password-stdin 12 | - export DOCKER_IMAGE_NAME=vindr/vinlab-dashboard:latest 13 | 14 | image: node:lts 15 | 16 | cache: 17 | key: ${CI_COMMIT_REF_SLUG} 18 | paths: 19 | - node_modules/ 20 | 21 | before_script: 22 | - ls -la . 23 | - node -v 24 | 25 | stages: 26 | - build-docker-image 27 | 28 | build-docker-image:latest: 29 | <<: *build-docker-image 30 | stage: build-docker-image 31 | only: 32 | - master 33 | - develop 34 | - cicd 35 | - open_source 36 | script: 37 | - docker build -t ${DOCKER_IMAGE_NAME} . 38 | - docker push ${DOCKER_IMAGE_NAME} 39 | 40 | build-docker-image:tags: 41 | <<: *build-docker-image 42 | stage: build-docker-image 43 | only: 44 | - tags 45 | script: 46 | - docker build -t ${DOCKER_IMAGE_NAME}:${CI_COMMIT_REF_NAME} . 47 | - docker push ${DOCKER_IMAGE_NAME}:${CI_COMMIT_REF_NAME} 48 | -------------------------------------------------------------------------------- /src/assets/icons/ic_menu_project.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/utils/constants/actions.js: -------------------------------------------------------------------------------- 1 | export const PENDING = 'PENDING'; 2 | export const SUCCESS = 'SUCCESS'; 3 | export const ERROR = 'ERROR'; 4 | export const SHOW_LOADING = 'SHOW_LOADING'; 5 | export const HIDE_LOADING = 'HIDE_LOADING'; 6 | export const FETCH_ROLES = 'FETCH_ROLES'; 7 | export const SHOW_UPLOAD_MODAL = 'SHOW_UPLOAD_MODAL'; 8 | 9 | // profile 10 | export const CHANGE_LANGUAGE = 'CHANGE_LANGUAGE'; 11 | export const FETCHING_PROFILE = 'FETCHING_PROFILE'; 12 | 13 | export const FETCH_STATS_STUDIES = 'FETCH_STATS_STUDIES'; 14 | export const FETCHING_STUDIES = 'FETCHING_STUDIES'; 15 | export const FETCH_STUDIES = 'FETCH_STUDIES'; 16 | export const FETCHING_TASKS = 'FETCHING_TASKS'; 17 | export const FETCH_TASKS= 'FETCH_TASKS'; 18 | export const FETCH_EXPORTED_VERSIONS = 'FETCH_EXPORTED_VERSIONS'; 19 | export const FETCHING_PROJECTS = 'FETCHING_PROJECTS'; 20 | export const FETCH_PROJECTS = 'FETCH_PROJECTS'; 21 | export const FETCH_PROJECT_DETAIL = 'FETCH_PROJECT_DETAIL'; 22 | export const FETCHING_LABELS = 'FETCHING_LABELS'; 23 | export const FETCH_LABELS = 'FETCH_LABELS'; 24 | export const FETCHING_LABEL_GROUPS = 'FETCHING_LABEL_GROUPS'; 25 | export const FETCH_LABEL_GROUPS = 'FETCH_LABEL_GROUPS'; 26 | export const SET_SELECTED_LABEL_GROUPS = 'SET_SELECTED_LABEL_GROUPS'; 27 | export const FETCHING_USERS = 'FETCHING_USERS'; 28 | export const FETCH_USERS = 'FETCH_USERS'; 29 | export const CHANGE_VIEW_MODE = 'CHANGE_VIEW_MODE'; -------------------------------------------------------------------------------- /src/components/layout/header/Header.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables.scss'; 2 | 3 | .ant-layout-header { 4 | background: $app-bg-color; 5 | padding: 0 8px; 6 | height: $header-height; 7 | line-height: $header-height; 8 | color: $defautl-font-color; 9 | 10 | .header-container { 11 | display: flex; 12 | justify-content: space-between; 13 | align-items: center; 14 | height: $header-height; 15 | 16 | .header-left-content { 17 | height: $header-height; 18 | display: flex; 19 | align-items: center; 20 | .app-logo { 21 | cursor: pointer; 22 | .img-logo { 23 | height: 34px; 24 | max-width: 135px; 25 | } 26 | } 27 | } 28 | 29 | .project-name { 30 | font-size: 16px; 31 | } 32 | 33 | .header-right-content { 34 | display: flex; 35 | .user-info { 36 | display: flex; 37 | align-items: center; 38 | cursor: default; 39 | margin-left: 40px; 40 | font-weight: 500; 41 | .user-name { 42 | margin-right: 8px; 43 | } 44 | } 45 | 46 | .switch-view-mode { 47 | .ant-radio-button-wrapper { 48 | font-size: 12px; 49 | } 50 | .ant-radio-button-wrapper:not(.ant-radio-button-wrapper-checked) { 51 | background: transparent; 52 | color: $defautl-font-color; 53 | border-color: $app-box-border-color; 54 | } 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/assets/icons/ic_menu_new_project.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/view/system/systemReducer.js: -------------------------------------------------------------------------------- 1 | import cookie from 'js-cookie'; 2 | import { VINLAB_LOCALE } from '../../utils/constants/config'; 3 | import * as actions from '../../utils/constants/actions'; 4 | 5 | const initialState = { 6 | locale: cookie.get(VINLAB_LOCALE) || 'en', 7 | isLoading: false, 8 | profile: {}, 9 | users: {}, 10 | isFetchingUser: false, 11 | uploadInfoModal: { 12 | isShow: false, 13 | projectId: '', 14 | studyParams: {}, 15 | }, 16 | viewMode: '', 17 | }; 18 | 19 | const system = (state = initialState, action) => { 20 | switch (action.type) { 21 | case actions.CHANGE_LANGUAGE: 22 | return { ...state, locale: action.payload }; 23 | case actions.FETCHING_PROFILE: 24 | return { ...state, profile: action.payload }; 25 | case actions.SHOW_LOADING: 26 | return { ...state, isLoading: true }; 27 | case actions.HIDE_LOADING: 28 | return { ...state, isLoading: false }; 29 | case actions.FETCH_USERS: 30 | return { ...state, users: action.payload, isFetchingUser: false }; 31 | case actions.FETCHING_USERS: 32 | return { ...state, isFetchingUser: action.payload }; 33 | case actions.CHANGE_VIEW_MODE: 34 | return { ...state, viewMode: action.payload }; 35 | case actions.SHOW_UPLOAD_MODAL: 36 | return { 37 | ...state, 38 | uploadInfoModal: { ...state.uploadInfoModal, ...action.payload }, 39 | }; 40 | default: 41 | return state; 42 | } 43 | }; 44 | 45 | export default system; 46 | -------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | const CracoLessPlugin = require('craco-less'); 2 | const webpack = require('webpack'); 3 | 4 | let buildMode = 'local'; 5 | if (process.argv.indexOf('dev') > -1) buildMode = 'dev'; 6 | if (process.argv.indexOf('prod') > -1) buildMode = 'prod'; 7 | if (process.argv.indexOf('stg') > -1) buildMode = 'stg'; 8 | const raw = Object.keys(process.env).reduce( 9 | (env, key) => { 10 | env[key] = process.env[key]; 11 | return env; 12 | }, 13 | { 14 | BUILD_TIME: new Date(), 15 | BUILD_USERNAME: process.env.USERNAME, 16 | BUILD_MODE: buildMode, 17 | } 18 | ); 19 | 20 | module.exports = { 21 | babel: { 22 | plugins: [ 23 | ['import', { libraryName: 'antd', libraryDirectory: 'es', style: true }], 24 | ], 25 | }, 26 | webpack: { 27 | plugins: [ 28 | new webpack.DefinePlugin({ 29 | 'process.env': Object.keys(raw).reduce((env, key) => { 30 | env[key] = JSON.stringify(raw[key]); 31 | return env; 32 | }, {}), 33 | }), 34 | ], 35 | configure: (webpackConfig, { env, paths }) => { 36 | return webpackConfig; 37 | }, 38 | }, 39 | plugins: [ 40 | { 41 | plugin: CracoLessPlugin, 42 | options: { 43 | lessLoaderOptions: { 44 | lessOptions: { 45 | modifyVars: { 46 | '@primary-color': '#17B978', 47 | '@text-selection-bg': '#1890ff', 48 | }, 49 | javascriptEnabled: true, 50 | }, 51 | }, 52 | }, 53 | }, 54 | ], 55 | }; 56 | -------------------------------------------------------------------------------- /src/view/labelManagement/LabelManagement.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Row, Col } from 'antd'; 3 | import { connect } from 'react-redux'; 4 | import { useIntl } from 'react-intl'; 5 | import { USER_ROLES } from '../../utils/constants/config'; 6 | import { hasRolePO } from '../system/systemAction'; 7 | import LabelGroups from '../../components/labels/LabelGroups'; 8 | import Labels from '../../components/labels/Labels'; 9 | import './LabelManagement.scss'; 10 | 11 | const LabelManagement = (props) => { 12 | const { userInfo, viewMode } = props; 13 | const { formatMessage: t } = useIntl(); 14 | 15 | const isViewAsPO = viewMode === USER_ROLES.PROJECT_OWNER; 16 | 17 | if (!hasRolePO(userInfo) || !isViewAsPO) return null; 18 | 19 | return ( 20 |
21 |
22 |
23 |
{t({ id: 'IDS_LABEL_MANAGEMENT' })}
24 |
25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
35 | ); 36 | }; 37 | 38 | export default connect( 39 | (state) => ({ 40 | userInfo: state.system.profile, 41 | viewMode: state.system.viewMode, 42 | selectedLabelGroup: state.label.selectedLabelGroup, 43 | }), 44 | null 45 | )(LabelManagement); 46 | -------------------------------------------------------------------------------- /src/assets/icons/ic_menu_label.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vindr-labeling-studylist", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "./", 6 | "dependencies": { 7 | "@babel/runtime": "^7.12.5", 8 | "@craco/craco": "^5.8.0", 9 | "@testing-library/jest-dom": "^5.11.6", 10 | "@testing-library/react": "^11.2.2", 11 | "@testing-library/user-event": "^12.2.2", 12 | "antd": "^4.8.5", 13 | "axios": "^0.21.0", 14 | "babel-plugin-import": "^1.13.1", 15 | "craco-less": "^1.17.0", 16 | "env-cmd": "^10.1.0", 17 | "js-cookie": "^2.2.1", 18 | "moment": "^2.29.1", 19 | "node-sass": "~4.14.1", 20 | "p-queue": "^6.6.2", 21 | "qs": "^6.9.4", 22 | "query-string": "^6.13.7", 23 | "react": "^17.0.1", 24 | "react-beautiful-dnd": "^13.0.0", 25 | "react-circular-progressbar": "^2.0.3", 26 | "react-color": "^2.19.3", 27 | "react-dom": "^17.0.1", 28 | "react-infinite-scroller": "^1.2.4", 29 | "react-intl": "^5.10.4", 30 | "react-redux": "^7.2.2", 31 | "react-router-dom": "^5.2.0", 32 | "react-scripts": "4.0.1", 33 | "react-virtualized": "^9.22.2", 34 | "redux": "^4.0.5", 35 | "redux-thunk": "^2.3.0" 36 | }, 37 | "scripts": { 38 | "start": "set \"PORT=3001\" && env-cmd -f .env.local craco start local", 39 | "build:dev": "set \"GENERATE_SOURCEMAP=true\" && craco build dev", 40 | "build:stg": "set \"GENERATE_SOURCEMAP=false\" && craco build stg", 41 | "build:prod": "set \"GENERATE_SOURCEMAP=false\" && craco build prod", 42 | "build": "set \"GENERATE_SOURCEMAP=false\" && craco build prod", 43 | "test": "craco test", 44 | "eject": "react-scripts eject" 45 | }, 46 | "eslintConfig": { 47 | "extends": "react-app" 48 | }, 49 | "browserslist": { 50 | "production": [ 51 | ">0.2%", 52 | "not dead", 53 | "not op_mini all" 54 | ], 55 | "development": [ 56 | "last 1 chrome version", 57 | "last 1 firefox version", 58 | "last 1 safari version" 59 | ] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/components/uploadDatasets/ImportDataModal.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Modal } from 'antd'; 3 | import { 4 | CloseOutlined, 5 | MinusOutlined, 6 | UpCircleOutlined, 7 | } from '@ant-design/icons'; 8 | import UploadDatasets from './index'; 9 | 10 | const ImportDataModal = (props) => { 11 | const { visible = true, onCancel, projectId } = props; 12 | const [processing, setProcessing] = useState(false); 13 | const [minimize, setMinimize] = useState(false); 14 | 15 | const handleCancel = () => { 16 | if (processing) return; 17 | if (onCancel) onCancel(); 18 | }; 19 | 20 | const handleMinimize = (isMinimize = false) => { 21 | setMinimize(isMinimize); 22 | }; 23 | 24 | return ( 25 | 35 | {minimize && ( 36 | handleMinimize()} 39 | /> 40 | )} 41 | {!minimize && ( 42 | <> 43 | handleMinimize(true)} 46 | /> 47 | 51 | 52 | )} 53 | 54 | } 55 | > 56 | { 59 | setProcessing(isUploading); 60 | }} 61 | isMinimize={minimize} 62 | onMinimize={() => handleMinimize(true)} 63 | onCancel={handleCancel} 64 | /> 65 | 66 | ); 67 | }; 68 | 69 | export default ImportDataModal; 70 | -------------------------------------------------------------------------------- /src/view/project/ProjectAction.js: -------------------------------------------------------------------------------- 1 | import api from '../../utils/service/api'; 2 | import * as actionType from '../../utils/constants/actions'; 3 | 4 | export const actionGetProjects = (params = {}) => async (dispatch) => { 5 | try { 6 | dispatch({ type: actionType.FETCHING_PROJECTS, payload: true }); 7 | const { data } = await api({ 8 | method: 'get', 9 | url: '/api/projects', 10 | params, 11 | }); 12 | dispatch({ type: actionType.FETCH_PROJECTS, payload: data }); 13 | } catch (error) { 14 | dispatch({ type: actionType.FETCHING_PROJECTS, payload: false }); 15 | } 16 | }; 17 | 18 | export const getProjects = (params = {}) => { 19 | return api({ 20 | method: 'get', 21 | url: '/api/stats/projects_by_role', 22 | params, 23 | }); 24 | }; 25 | 26 | export const actionGetProjectDetail = (projectId = '') => async (dispatch) => { 27 | try { 28 | const { data } = await api({ 29 | method: 'get', 30 | url: '/api/projects/' + projectId, 31 | }); 32 | dispatch({ 33 | type: actionType.FETCH_PROJECT_DETAIL, 34 | payload: data?.data || {}, 35 | }); 36 | } catch (error) { 37 | console.log(error); 38 | } 39 | }; 40 | 41 | export const actionSetProjectDetail = (data = {}) => (dispatch) => { 42 | dispatch({ 43 | type: actionType.FETCH_PROJECT_DETAIL, 44 | payload: data, 45 | }); 46 | }; 47 | 48 | export const actionDeleteProject = (id = '') => { 49 | return api({ 50 | method: 'delete', 51 | url: '/api/projects/' + id, 52 | }); 53 | }; 54 | 55 | export const actionUpdateProject = (id = '', data = {}) => { 56 | return api({ 57 | method: 'put', 58 | url: '/api/projects/' + id, 59 | data, 60 | }); 61 | }; 62 | 63 | export const actionUpdateUserToProject = (id = '', data = {}) => { 64 | return api({ 65 | method: 'put', 66 | url: '/api/projects/' + id + '/people', 67 | data, 68 | }); 69 | }; 70 | 71 | export const actionCreateProject = (data = {}) => { 72 | return api({ 73 | method: 'post', 74 | url: '/api/projects', 75 | data, 76 | }); 77 | }; 78 | -------------------------------------------------------------------------------- /src/view/project/Project.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables.scss'; 2 | 3 | .project-page { 4 | display: flex; 5 | flex-direction: column; 6 | position: relative; 7 | .top-content { 8 | display: flex; 9 | align-items: center; 10 | justify-content: space-between; 11 | padding: 0 8px; 12 | height: 53px; 13 | .page-header { 14 | display: flex; 15 | align-items: flex-end; 16 | .btn-create-project { 17 | margin-left: 12px; 18 | .anticon-plus-circle { 19 | font-size: 22px; 20 | } 21 | } 22 | } 23 | 24 | } 25 | .page-content { 26 | flex: 1; 27 | padding: 20px; 28 | max-height: calc(100vh - 109px); 29 | overflow: hidden; 30 | overflow-y: auto; 31 | 32 | .loading-project { 33 | position: absolute; 34 | bottom: 80px; 35 | left: 45%; 36 | } 37 | } 38 | 39 | .project-list { 40 | .card-project { 41 | width: 264px; 42 | height: 292px; 43 | color: $defautl-font-color; 44 | background: $app-bg-box; 45 | border-radius: $border-box-radius; 46 | margin-bottom: 20px; 47 | display: flex; 48 | flex-direction: column; 49 | 50 | .ant-card-cover { 51 | width: 140px; 52 | display: flex; 53 | margin: 0 auto; 54 | flex: 1; 55 | align-items: center; 56 | } 57 | 58 | .ant-card-body { 59 | padding: 14px; 60 | .ant-card-meta { 61 | background: #363a42; 62 | padding: 0 22px; 63 | border-radius: $border-box-radius; 64 | height: 95px; 65 | display: flex; 66 | align-items: center; 67 | .ant-card-meta-detail { 68 | .ant-card-meta-title { 69 | color: $defautl-font-color; 70 | } 71 | .ant-card-meta-description { 72 | color: $defautl-font-color; 73 | font-size: 13px; 74 | } 75 | } 76 | .total-study { 77 | color: $app-primary-color; 78 | margin-left: 6px; 79 | text-transform: lowercase; 80 | 81 | } 82 | } 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 25 | 29 | Vin Labelling 30 | 31 | 35 | 43 | 44 | 45 | 46 |
47 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/view/studyList/data/Data.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables.scss'; 2 | 3 | .data-page { 4 | display: flex; 5 | flex-direction: column; 6 | flex: 1; 7 | 8 | .data-container { 9 | .table-wrapper { 10 | .table-content { 11 | .study-status { 12 | color: $defautl-font-color; 13 | &.assigned { 14 | color: #ffa000; 15 | } 16 | &.completed { 17 | color: $app-primary-color; 18 | } 19 | } 20 | } 21 | } 22 | 23 | .right-panel { 24 | .right-btn-group { 25 | padding: 8px 0; 26 | display: flex; 27 | flex-direction: column; 28 | align-items: center; 29 | .btn-action { 30 | width: 100%; 31 | max-width: 220px; 32 | border-radius: $border-box-radius; 33 | &.btn-import { 34 | margin-bottom: 8px; 35 | } 36 | } 37 | } 38 | } 39 | } 40 | } 41 | 42 | .assign-labelers { 43 | max-height: 250px; 44 | overflow: hidden; 45 | overflow-y: auto; 46 | .assign-btn-group { 47 | margin-bottom: 15px; 48 | text-align: center; 49 | } 50 | .labelers { 51 | margin-bottom: 20px; 52 | .check-box-list { 53 | display: flex; 54 | flex-direction: column; 55 | } 56 | } 57 | } 58 | 59 | .tooltip-assign { 60 | .ant-tooltip-inner { 61 | color: rgba(0, 0, 0, 0.85); 62 | } 63 | } 64 | 65 | .delete-modal { 66 | .delete-modal-content { 67 | margin-top: 15px; 68 | .line-item { 69 | display: flex; 70 | align-items: center; 71 | .line-icon { 72 | font-size: 24px; 73 | margin-right: 10px; 74 | &.warning-icon { 75 | color: #faad14; 76 | } 77 | &.success-icon { 78 | color: $app-primary-color; 79 | } 80 | &.error-icon { 81 | color: #ff4d4f; 82 | } 83 | } 84 | .msg { 85 | font-size: 18px; 86 | } 87 | } 88 | } 89 | .ant-modal-footer { 90 | .btn-delete { 91 | border: 1px solid #ff4d4f !important; 92 | } 93 | } 94 | } 95 | 96 | .assign-labeler-modal { 97 | .form-content { 98 | .ant-input-number { 99 | width: 100%; 100 | } 101 | } 102 | } 103 | 104 | .export-label-modal { 105 | .exported-table { 106 | .export-lb-status { 107 | text-transform: capitalize; 108 | &.pending { 109 | color: #ffa000; 110 | } 111 | &.done { 112 | color: #17b978; 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/components/labels/AddEditGroup.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Form, Modal, message, Input, Spin } from 'antd'; 3 | import { actionCreateLabelGroup, actionUpdateLabelGroup } from './LabelsAction'; 4 | 5 | const AddEditGroup = (props) => { 6 | const { visible = true, onCancel, onOk, isEdit, selectedGroup = {} } = props; 7 | const [processing, setProcessing] = useState(false); 8 | const [form] = Form.useForm(); 9 | 10 | const handleOk = (event) => { 11 | if (processing) return; 12 | event.stopPropagation(); 13 | form 14 | .validateFields() 15 | .then(async (values) => { 16 | try { 17 | setProcessing(true); 18 | const dataDTO = { 19 | name: values.name?.trim(), 20 | }; 21 | let resData = {}; 22 | if (isEdit) { 23 | const { data } = await actionUpdateLabelGroup( 24 | selectedGroup.id, 25 | dataDTO 26 | ); 27 | resData = data?.data || {}; 28 | } else { 29 | const { data } = await actionCreateLabelGroup(dataDTO); 30 | resData = data?.data || {}; 31 | } 32 | 33 | setProcessing(false); 34 | if (onOk) onOk(resData); 35 | } catch (error) { 36 | message.error('Error'); 37 | setProcessing(false); 38 | } 39 | }) 40 | .catch((error) => {}); 41 | }; 42 | 43 | const handleCancel = () => { 44 | if (processing) return; 45 | if (onCancel) onCancel(); 46 | }; 47 | 48 | return ( 49 | 59 | 60 |
61 |
62 | 76 | 77 | 78 |
79 |
80 |
81 |
82 | ); 83 | }; 84 | 85 | export default AddEditGroup; 86 | -------------------------------------------------------------------------------- /src/components/labels/LabelsAction.js: -------------------------------------------------------------------------------- 1 | import api from '../../utils/service/api'; 2 | import * as actionType from '../../utils/constants/actions'; 3 | 4 | export const actionGetLabels = (params = {}) => async (dispatch) => { 5 | try { 6 | dispatch({ type: actionType.FETCHING_LABELS, payload: true }); 7 | const { data } = await api({ 8 | method: 'get', 9 | url: '/api/labels', 10 | params, 11 | }); 12 | dispatch({ type: actionType.FETCH_LABELS, payload: data }); 13 | } catch (error) { 14 | dispatch({ type: actionType.FETCHING_LABELS, payload: false }); 15 | } 16 | }; 17 | 18 | export const getLabelList = (params = {}) => { 19 | return api({ 20 | method: 'get', 21 | url: '/api/stats/agg_labels', 22 | params, 23 | }); 24 | }; 25 | 26 | export const actionGetLabelGroups = (params = {}) => async (dispatch) => { 27 | try { 28 | dispatch({ type: actionType.FETCHING_LABEL_GROUPS, payload: true }); 29 | const { data } = await api({ 30 | method: 'get', 31 | url: '/api/label_groups', 32 | params, 33 | }); 34 | dispatch({ type: actionType.FETCH_LABEL_GROUPS, payload: data }); 35 | } catch (error) { 36 | dispatch({ type: actionType.FETCHING_LABEL_GROUPS, payload: false }); 37 | } 38 | }; 39 | 40 | export const actionSetLabelGroups = (selectedGroup = {}) => async ( 41 | dispatch 42 | ) => { 43 | dispatch({ 44 | type: actionType.SET_SELECTED_LABEL_GROUPS, 45 | payload: selectedGroup, 46 | }); 47 | }; 48 | 49 | export const actionCreateLabel = (data = {}) => { 50 | return api({ method: 'post', url: '/api/labels', data }); 51 | }; 52 | 53 | export const actionUpdateLabel = (id = '', data = {}) => { 54 | return api({ method: 'put', url: '/api/labels/' + id, data }); 55 | }; 56 | 57 | export const actionDeleteLabel = (id = '') => { 58 | return api({ method: 'delete', url: '/api/labels/' + id }); 59 | }; 60 | 61 | export const actionCreateLabelGroup = (data = {}) => { 62 | return api({ method: 'post', url: '/api/label_groups', data }); 63 | }; 64 | 65 | export const actionUpdateLabelGroup = (id = '', data = {}) => { 66 | return api({ method: 'put', url: '/api/label_groups/' + id, data }); 67 | }; 68 | 69 | export const actionDeleteLabelGroup = (id = '') => { 70 | return api({ method: 'delete', url: '/api/label_groups/' + id }); 71 | }; 72 | 73 | export const actionAssignLabel = (data = {}, projectId) => { 74 | return api({ method: 'put', url: '/api/projects/' + projectId, data }); 75 | }; 76 | 77 | export const updateLabelOrder = (groupId = '', data = {}) => { 78 | return api({ 79 | method: 'put', 80 | url: '/api/label_groups/' + groupId + '/update_order', 81 | data, 82 | }); 83 | }; 84 | -------------------------------------------------------------------------------- /src/components/pagination/PaginationTable.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables.scss'; 2 | 3 | .pagination-table { 4 | text-align: right; 5 | display: flex; 6 | align-items: center; 7 | justify-content: flex-end; 8 | font-size: 12px; 9 | 10 | .page-size-option { 11 | color: $defautl-font-color; 12 | margin: 0 10px; 13 | font-size: 12px; 14 | .ant-select-selector { 15 | background: #151515; 16 | border: none; 17 | height: 24px; 18 | .ant-select-selection-item { 19 | line-height: 24px; 20 | } 21 | } 22 | .ant-select-arrow { 23 | color: $defautl-font-color; 24 | font-size: 10px; 25 | } 26 | } 27 | 28 | .ant-pagination { 29 | font-size: 12px; 30 | .ant-pagination-item-link { 31 | .ant-pagination-item-ellipsis { 32 | color: rgba(209, 209, 215, 0.4); 33 | } 34 | } 35 | .ant-pagination-prev, 36 | .ant-pagination-next { 37 | width: 24px; 38 | min-width: 24px; 39 | height: 24px; 40 | line-height: 24px; 41 | .ant-pagination-item-link { 42 | border-radius: 50%; 43 | background: transparent; 44 | color: $defautl-font-color; 45 | border-width: 0; 46 | } 47 | &:hover, 48 | &:focus { 49 | .ant-pagination-item-link { 50 | color: #00b894; 51 | } 52 | } 53 | } 54 | .ant-pagination-jump-prev, 55 | .ant-pagination-jump-next { 56 | .ant-pagination-item-link-icon { 57 | color: #00b894; 58 | } 59 | } 60 | .ant-pagination-item { 61 | border: none; 62 | background: inherit; 63 | text-decoration: underline; 64 | a { 65 | color: $defautl-font-color; 66 | } 67 | &:hover, 68 | &:focus { 69 | a { 70 | color: #00b894; 71 | } 72 | } 73 | &.ant-pagination-item-active { 74 | a { 75 | color: #00b894; 76 | } 77 | } 78 | } 79 | } 80 | 81 | .ant-pagination-disabled { 82 | .ant-pagination-prev, 83 | .ant-pagination-next { 84 | &:hover, 85 | &:focus { 86 | .ant-pagination-item-link { 87 | color: #354053; 88 | border-color: #354053; 89 | } 90 | } 91 | } 92 | .ant-pagination-item { 93 | &:hover, 94 | &:focus { 95 | a { 96 | color: #354053; 97 | } 98 | } 99 | &.ant-pagination-item-active { 100 | a { 101 | color: #00b894; 102 | } 103 | } 104 | } 105 | } 106 | .ant-pagination-disabled { 107 | &.ant-pagination-prev, 108 | &.ant-pagination-next { 109 | &:hover, 110 | &:focus { 111 | .ant-pagination-item-link { 112 | color: #354053; 113 | border-color: #354053; 114 | } 115 | } 116 | } 117 | } 118 | .info-page-size { 119 | background: rgba(194, 207, 224, 0.36); 120 | border-radius: 70px; 121 | padding: 5px 14px; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/components/layout/leftMenu/LeftMenu.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Menu, Layout } from 'antd'; 3 | import { FormattedMessage } from 'react-intl'; 4 | import { withRouter } from 'react-router-dom'; 5 | import { connect } from 'react-redux'; 6 | import { routes, ROLES } from '../../../utils/constants/config'; 7 | import { 8 | IconMenuProject, 9 | IconCollapse, 10 | IconMenuLabel, 11 | } from '../../../assets'; 12 | import { checkRole } from '../../../view/system/systemAction'; 13 | import './LeftMenu.scss'; 14 | 15 | const LeftMenu = (props) => { 16 | const { location, userInfo } = props; 17 | const [collapsed, setCollapsed] = useState(false); 18 | const [selectedKeys, setSelectedKeys] = useState(); 19 | 20 | useEffect(() => { 21 | const { pathname } = location; 22 | if (pathname?.indexOf(routes.STUDY_LIST) > -1) { 23 | setSelectedKeys(routes.PROJECTS); 24 | } else { 25 | setSelectedKeys(pathname); 26 | } 27 | }, [location]); 28 | 29 | const onCollapse = (isCollapse) => { 30 | setCollapsed(isCollapse); 31 | }; 32 | 33 | const handleMenuClick = ({ key }) => { 34 | setSelectedKeys(key); 35 | props.history.push(key); 36 | }; 37 | 38 | const menuList = [ 39 | { 40 | icon: , 41 | text: , 42 | route: routes.PROJECTS, 43 | isShow: true, 44 | }, 45 | { 46 | icon: , 47 | text: , 48 | route: routes.LABEL_MANAGEMENT, 49 | isShow: 50 | checkRole(userInfo, ROLES.PO) || checkRole(userInfo, ROLES.PO_PARTNER), 51 | }, 52 | ]; 53 | 54 | return ( 55 | } 61 | > 62 |
63 |
64 | 70 | {menuList.map((el) => { 71 | if (el.isShow) { 72 | return ( 73 | 78 | {el.icon} 79 | 80 | ) : null 81 | } 82 | > 83 | {el.text} 84 | 85 | ); 86 | } 87 | return null; 88 | })} 89 | 90 |
91 |
92 |
93 | ); 94 | }; 95 | 96 | export default connect( 97 | (state) => ({ userInfo: state.system.profile }), 98 | null 99 | )(withRouter(LeftMenu)); 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # VinDr Lab / Dashboard 4 | Vindr Lab Dashboard is a part of the VinLab project. It allows users to manage Projects, Labels, Task, Export label, Upload DICOM files and many more features. 5 | 6 | ## Developing 7 | The Vindr Lab project consists of two parts. So let's clone and run both projects together. 8 | - [Vindr Lab Dashboard][vindr-lab-dashboard-url]: To manage Projects, Study list, Label, setting, Export label,... 9 | - [Vindr Lab Viewer][vindr-lab-viewer-url]: For viewing medical images, labeling (bounding box, polygon, segment). 10 | 11 | ### Requirements 12 | 13 | - [Yarn 1.17.3+](https://yarnpkg.com/en/docs/install) 14 | - [Node 10+](https://nodejs.org/en/) 15 | - Yarn Workspaces should be enabled on your machine: 16 | - `yarn config set workspaces-experimental true` 17 | 18 | ### Getting Started 19 | 20 | 1. [Fork this repository][how-to-fork] 21 | 2. [Clone your forked repository][how-to-clone] 22 | - `git clone https://github.com/YOUR-USERNAME/vindr-lab-dashboard.git` 23 | 3. Navigate to the cloned project's directory 24 | 4. Add this repo as a `remote` named `upstream` 25 | - `git remote add upstream https://github.com/vinbigdata-medical/vindr-lab-dashboard.git` 26 | 5. `yarn install` to restore dependencies and link projects 27 | 28 | ## Commands 29 | 30 | These commands are available from the root directory. 31 | 32 | | Yarn Commands | Description | 33 | | ---------------------------- | ------------------------------------------------------------- | 34 | | **Develop** | | 35 | | `start` | Default development experience for Dashboard | 36 | | **Deploy** | | 37 | | `build` or `build:prod` | Builds production environment | 38 | | `build:stg` | Builds staging environment | 39 | | `build:dev` | Builds Builds develop environment | 40 | 41 | 42 | ## Projects Architecture 43 | 44 | ```bash 45 | . 46 | ├── public # 47 | ├── src # 48 | │ ├── assets # images and icons 49 | │ ├── components # Reusable React components 50 | │ ├── utils # locale, helpers, constants and service files 51 | │ └── view # 52 | │ 53 | ├── ... # misc. shared configuration 54 | ├── package.json # Shared devDependencies and commands 55 | └── README.md # This file 56 | ``` 57 | 58 | ## Acknowledgement 59 | 60 | **Note:** If you use or find this repository helpful, please take the time to star this repository on Github. This is an easy way for us to assess adoption and it can help us obtain future funding for the project. 61 | 62 | This work is supported primarily by [Vingroup Big Data Institute](http://vinbigdata.org/) 63 | ## License 64 | 65 | [MIT License](https://github.com/vinbigdata-medical/vinlab-sites/blob/master/LICENSE) 66 | 67 | 68 | 69 | [vindr-lab-dashboard-url]: https://github.com/vinbigdata-medical/vindr-lab-dashboard 70 | [vindr-lab-viewer-url]: https://github.com/vinbigdata-medical/vindr-lab-viewer 71 | 72 | -------------------------------------------------------------------------------- /src/components/layout/leftMenu/LeftMenu.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables.scss'; 2 | 3 | @mixin setLeftMenuWidth($width) { 4 | width: $width !important; 5 | flex: 0 0 $width !important; 6 | max-width: $width !important; 7 | min-width: $width !important; 8 | } 9 | 10 | .left-layout-sider { 11 | @include setLeftMenuWidth($left-menu-width); 12 | position: relative; 13 | padding-bottom: 0; 14 | background: $app-bg-color; 15 | 16 | &.ant-layout-sider-collapsed { 17 | @include setLeftMenuWidth($left-menu-collapsed-width); 18 | .ant-layout-sider-trigger { 19 | svg { 20 | transform: rotate(180deg); 21 | path { 22 | fill: $app-primary-color; 23 | } 24 | } 25 | } 26 | .ant-menu-item { 27 | .anticon { 28 | margin: 0 !important; 29 | } 30 | } 31 | } 32 | 33 | .ant-layout-sider-trigger { 34 | width: 10px !important; 35 | position: absolute; 36 | right: 0; 37 | top: 50%; 38 | z-index: 1; 39 | background: rgba(194, 207, 224, 0.6); 40 | border-top-left-radius: 8px; 41 | border-bottom-left-radius: 8px; 42 | height: 27px; 43 | line-height: 27px; 44 | text-align: left; 45 | svg { 46 | margin-left: 3px; 47 | path { 48 | fill: $app-primary-color; 49 | } 50 | } 51 | } 52 | 53 | .sider-container { 54 | height: 100%; 55 | display: flex; 56 | flex-direction: column; 57 | justify-content: space-between; 58 | box-shadow: 6px 0px 18px rgba(0, 0, 0, 0.06); 59 | padding: 8px 0 8px 8px; 60 | .left-menu-wrapper { 61 | height: 100%; 62 | background: $app-bg-box; 63 | border-radius: $border-box-radius; 64 | overflow: hidden; 65 | .menu-list { 66 | border: none; 67 | color: $defautl-font-color; 68 | font-weight: 500; 69 | font-size: 13px; 70 | background: transparent; 71 | &.ant-menu-inline-collapsed { 72 | width: 100%; 73 | max-width: 75px; 74 | .ant-menu-item { 75 | width: 100%; 76 | display: flex; 77 | align-items: center; 78 | justify-content: center; 79 | padding: 0; 80 | .anticon { 81 | line-height: normal; 82 | display: flex; 83 | } 84 | } 85 | } 86 | 87 | .ant-menu-item { 88 | width: 100%; 89 | .anticon { 90 | svg { 91 | width: 22px; 92 | height: 22px; 93 | } 94 | } 95 | span { 96 | vertical-align: middle; 97 | } 98 | &.ant-menu-item-selected, 99 | &:hover { 100 | color: $app-primary-color; 101 | svg { 102 | path, 103 | circle, 104 | rect { 105 | fill: $app-primary-color; 106 | } 107 | } 108 | } 109 | &::after { 110 | content: none; 111 | } 112 | } 113 | } 114 | 115 | .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected { 116 | background-color: rgba(0, 184, 148, 0.2); 117 | &::after { 118 | border: none; 119 | } 120 | } 121 | } 122 | 123 | &.ant-layout-sider-collapsed { 124 | @include setLeftMenuWidth($left-menu-collapsed-width); 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/view/studyList/data/DeleteStudyModal.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Modal, message, Button, Spin } from 'antd'; 3 | import { 4 | ExclamationCircleOutlined, 5 | CheckCircleOutlined, 6 | CloseCircleOutlined, 7 | } from '@ant-design/icons'; 8 | import { useIntl } from 'react-intl'; 9 | import { actionDeleteStudies } from '../StudyListAction'; 10 | import { isEmpty } from '../../../utils/helpers'; 11 | 12 | const DeleteStudyModal = (props) => { 13 | const intl = useIntl(); 14 | const { formatMessage: t } = intl; 15 | const { visible = true, onCancel, selectedRowKeys } = props; 16 | const [processing, setProcessing] = useState(false); 17 | const [deletedInfo, setDeletedInfo] = useState(); 18 | 19 | const handleDelete = async () => { 20 | if (isEmpty(selectedRowKeys) || processing) return; 21 | 22 | try { 23 | setProcessing(true); 24 | const { data } = await actionDeleteStudies(selectedRowKeys); 25 | setDeletedInfo(data?.meta); 26 | setProcessing(false); 27 | } catch (error) { 28 | message.error('Error!'); 29 | setProcessing(false); 30 | } 31 | }; 32 | 33 | const handleClose = () => { 34 | if (onCancel) { 35 | onCancel(!isEmpty(deletedInfo)); 36 | } 37 | }; 38 | 39 | const footerAction = () => { 40 | let btns = [ 41 | , 44 | ]; 45 | if (isEmpty(deletedInfo)) { 46 | btns.push( 47 | 56 | ); 57 | } 58 | return btns; 59 | }; 60 | 61 | return ( 62 | 70 | 71 |
72 | {isEmpty(deletedInfo) && ( 73 |
74 | 75 | 76 | 77 | 78 | {t({ id: 'IDS_CONFIRM_DELETE_STUDY_MSG' })} 79 | 80 |
81 | )} 82 | {!isEmpty(deletedInfo) && ( 83 | <> 84 |
85 | 86 | 87 | 88 | 89 | {`${deletedInfo.deleted} files were deleted successfully.`} 90 | 91 |
92 | {deletedInfo?.not_deleted > 0 && ( 93 | <> 94 |
95 | 96 | 97 | 98 | 99 | Some files were already assigned. 100 | 101 |
102 |
103 | 104 | 105 | 106 | Some files were deleted failed. 107 |
108 | 109 | )} 110 | 111 | )} 112 |
113 |
114 |
115 | ); 116 | }; 117 | 118 | export default DeleteStudyModal; 119 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { Layout, message } from 'antd'; 3 | import cookie from 'js-cookie'; 4 | import { connect } from 'react-redux'; 5 | import Routes from './Routes'; 6 | import { LeftMenu, Header } from './components/layout'; 7 | import Loading from './components/loading/Loading'; 8 | import { 9 | getAccountInfo, 10 | actionGetToken, 11 | actionGetPermissionToken, 12 | actionShowLoading, 13 | requestLogin, 14 | actionShowUploadModal, 15 | actionLogout, 16 | actionGetListPermission, 17 | } from './view/system/systemAction'; 18 | import { 19 | TOKEN, 20 | REFRESH_TOKEN, 21 | FIRST_REFRESH_TOKEN, 22 | } from './utils/constants/config'; 23 | import ImportDataModal from './components/uploadDatasets/ImportDataModal'; 24 | import './App.scss'; 25 | 26 | const App = (props) => { 27 | const { uploadInfoModal = {} } = props; 28 | 29 | useEffect(() => { 30 | if (cookie.get(TOKEN) || cookie.get(REFRESH_TOKEN)) { 31 | props.getAccountInfo(); 32 | } 33 | // eslint-disable-next-line 34 | }, []); 35 | 36 | useEffect(() => { 37 | if (!cookie.get(TOKEN) && !cookie.get(REFRESH_TOKEN)) { 38 | const urlParams = new URLSearchParams(window.location.search); 39 | const code = urlParams.get('code'); 40 | const error = urlParams.get('error'); 41 | if (code) { 42 | getToken(code); 43 | } else if (!error) { 44 | requestLogin(); 45 | } else { 46 | const error_description = urlParams.get('error_description'); 47 | error_description && message.error(error_description || ''); 48 | } 49 | } 50 | // eslint-disable-next-line 51 | }); 52 | 53 | const getToken = async (code) => { 54 | try { 55 | props.actionShowLoading(); 56 | console.log('GET TOKEN'); 57 | const res = await actionGetToken(code); 58 | const resPermission = await actionGetListPermission( 59 | res?.data?.access_token 60 | ); 61 | cookie.set(FIRST_REFRESH_TOKEN, res?.data?.refresh_token, { 62 | expires: new Date( 63 | (res?.data?.refresh_expires_in || 1800) * 1000 + Date.now() 64 | ), 65 | }); 66 | const { data } = await actionGetPermissionToken( 67 | res?.data?.access_token, 68 | resPermission?.data?.data 69 | ); 70 | 71 | cookie.set(TOKEN, data?.access_token, { 72 | expires: new Date((data?.expires_in || 1800) * 1000 + Date.now()), 73 | }); 74 | cookie.set(REFRESH_TOKEN, data?.refresh_token, { 75 | expires: new Date( 76 | (data?.refresh_expires_in || 1800) * 1000 + Date.now() 77 | ), 78 | }); 79 | 80 | cookie.remove(FIRST_REFRESH_TOKEN); 81 | props.getAccountInfo(); 82 | } catch (error) { 83 | console.log(error); 84 | setTimeout(() => { 85 | actionLogout(); 86 | }, 1000); 87 | message.error('System error!'); 88 | } 89 | }; 90 | 91 | if (!cookie.get(TOKEN) && !cookie.get(REFRESH_TOKEN)) { 92 | return ; 93 | } 94 | 95 | return ( 96 |
97 | 98 | 99 |
100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | {uploadInfoModal.isShow && ( 108 | { 111 | props.actionShowUploadModal({ 112 | isShow: false, 113 | projectId: '', 114 | studyParams: {}, 115 | }); 116 | }} 117 | /> 118 | )} 119 |
120 | ); 121 | }; 122 | 123 | export default connect( 124 | (state) => ({ 125 | uploadInfoModal: state.system.uploadInfoModal, 126 | }), 127 | { getAccountInfo, actionShowLoading, actionShowUploadModal } 128 | )(App); 129 | -------------------------------------------------------------------------------- /src/utils/constants/config.js: -------------------------------------------------------------------------------- 1 | // common 2 | export const TOKEN = 'token'; 3 | export const REFRESH_TOKEN = 'refresh_token'; 4 | export const FIRST_REFRESH_TOKEN = 'first_refresh_token'; 5 | export const EXPIRED_REFRESH_TOKEN = 60 * 60 * 1000; 6 | export const VINLAB_LOCALE = 'vinlab-locale'; 7 | export const VINLAB_VIEW_MODE = 'vinlab_view_mode'; 8 | 9 | const { 10 | OIDC_ACCESS_TOKEN_URI, 11 | OIDC_AUTHORIZATION_URI, 12 | OIDC_REDIRECT_URI, 13 | OIDC_CLIENT_ID, 14 | OIDC_LOGOUT_URI, 15 | OIDC_USERINFO_ENDPOINT, 16 | OIDC_SCOPE, 17 | DASHBOARD_URL_PREFIX, 18 | SERVER_BASE_URL, 19 | MEDICAL_VIEWER_URL, 20 | OIDC_AUDIENCE, 21 | } = process.env || {}; 22 | 23 | // Config to run local for dashboard and viewer 24 | export const REDIRECT_VIEWER_URL = 25 | MEDICAL_VIEWER_URL || window.origin + '/medical-view/viewer'; 26 | 27 | export const BASE_ROUTER_PREFIX = DASHBOARD_URL_PREFIX || '/dashboard'; 28 | 29 | const BASE_URL = SERVER_BASE_URL || ''; 30 | 31 | export let CONFIG_SERVER = { 32 | BASE_URL: BASE_URL, 33 | LOGIN_CALLBACK_URI: OIDC_REDIRECT_URI || window.origin, 34 | CLIENT_ID: OIDC_CLIENT_ID || '', 35 | RESPONSE_TYPE: 'code', 36 | AUDIENCE: OIDC_AUDIENCE || '', 37 | SCOPE: OIDC_SCOPE || 'profile', 38 | STATE: Math.random().toString(36).substring(2), 39 | OIDC_ACCESS_TOKEN_URI: OIDC_ACCESS_TOKEN_URI, 40 | OIDC_AUTHORIZATION_URI: OIDC_AUTHORIZATION_URI, 41 | OIDC_LOGOUT_URI: OIDC_LOGOUT_URI, 42 | OIDC_USERINFO_ENDPOINT: OIDC_USERINFO_ENDPOINT, 43 | TOKEN_PERMISSION: ['api#all'], 44 | }; 45 | 46 | // routes 47 | export const routes = { 48 | LOGOUT: '/logout', 49 | PROJECTS: '/projects', 50 | STUDY_LIST: '/study-list', 51 | STUDY_LIST_ID: '/study-list/:projectId', 52 | LABEL_MANAGEMENT: '/label-management', 53 | }; 54 | 55 | export const STUDY_STATUS = { 56 | ALL: 'count', 57 | ASSIGNED: 'ASSIGNED', 58 | UNASSIGNED: 'UNASSIGNED', 59 | COMPLETED: 'COMPLETED', 60 | }; 61 | 62 | export const TASK_STATUS = { 63 | ALL: 'count', 64 | NEW: 'NEW', 65 | DOING: 'DOING', 66 | COMPLETED: 'COMPLETED', 67 | }; 68 | 69 | export const LABEL_TYPE = [ 70 | { text: 'Impression', value: 'IMPRESSION' }, 71 | { text: 'Finding', value: 'FINDING' }, 72 | ]; 73 | 74 | export const DRAW_SCOPE = { 75 | STUDY: 'STUDY', 76 | SERIES: 'SERIES', 77 | IMAGE: 'IMAGE', 78 | }; 79 | 80 | export const LABEL_SCOPE = [ 81 | { text: 'Study', value: DRAW_SCOPE.STUDY, isDisable: 'FINDING' }, 82 | { text: 'Series', value: DRAW_SCOPE.SERIES, isDisable: 'FINDING' }, 83 | { text: 'Image', value: DRAW_SCOPE.IMAGE }, 84 | ]; 85 | 86 | export const DRAW_TYPE = { 87 | TAG: 'TAG', 88 | BOUNDING_BOX: 'BOUNDING_BOX', 89 | POLYGON: 'POLYGON', 90 | MASK: 'MASK', 91 | }; 92 | 93 | export const ANNOTATION_TYPE = [ 94 | { text: 'Tag', value: DRAW_TYPE.TAG, isDisable: 'FINDING' }, 95 | { 96 | text: 'Bounding box', 97 | value: DRAW_TYPE.BOUNDING_BOX, 98 | isDisable: 'IMPRESSION', 99 | }, 100 | { text: 'Polygon', value: DRAW_TYPE.POLYGON, isDisable: 'IMPRESSION' }, 101 | { text: 'Mask', value: DRAW_TYPE.MASK, isDisable: 'IMPRESSION' }, 102 | ]; 103 | 104 | export const DEFAULT_COLOR_PICKER = [ 105 | '#f44336', 106 | '#e91e63', 107 | '#9c27b0', 108 | '#673ab7', 109 | '#3f51b5', 110 | '#2196f3', 111 | '#03a9f4', 112 | '#00bcd4', 113 | '#009688', 114 | '#4caf50', 115 | '#8bc34a', 116 | '#cddc39', 117 | '#ffeb3b', 118 | '#ffc107', 119 | '#ff9800', 120 | '#ff5722', 121 | '#795548', 122 | '#607d8b', 123 | ]; 124 | 125 | export const SESSION_TYPE = { 126 | STUDY: 'STUDY', 127 | TASK: 'TASK', 128 | }; 129 | 130 | export const ROLES = { 131 | PO: 'PO', 132 | PO_PARTNER: 'PO_PARTNER', 133 | LABELER: 'Labeler', 134 | }; 135 | 136 | export const STUDY_TABS = { 137 | DATA: 'DATA', 138 | TASK: 'TASK', 139 | SETTING: 'SETTING', 140 | ANNOTATE: 'ANNOTATE', 141 | REVIEW: 'REVIEW', 142 | }; 143 | 144 | export const WORKFLOW_PROJECT = { 145 | SINGLE: 'SINGLE', 146 | TRIANGLE: 'TRIANGLE', 147 | }; 148 | 149 | export const USER_ROLES = { 150 | ANNOTATOR: 'ANNOTATOR', 151 | REVIEWER: 'REVIEWER', 152 | PROJECT_OWNER: 'PROJECT_OWNER', 153 | }; 154 | -------------------------------------------------------------------------------- /src/utils/locale/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "IDS_COMMON_CHANGE_LANGUAGE": "Change language", 3 | "IDS_COMMON_LOGIN": "Login", 4 | "IDS_COMMON_LOGOUT": "Logout", 5 | "IDS_CREATE_PROJECT": "Create project", 6 | "IDS_MY_PROJECTS": "My Projects", 7 | "IDS_RECENT": "Recent", 8 | "IDS_LABEL_MANAGEMENT": "Label management", 9 | "IDS_SEARCH": "Search", 10 | "IDS_TASK_CODE": "Task code", 11 | "IDS_ASSIGNEE": "Assignee", 12 | "IDS_TYPE": "Type", 13 | "IDS_STUDY_CODE": "Study code", 14 | "IDS_LAST_OPEN": "Last open", 15 | "IDS_LAST_ACTIVITY": "Last activity", 16 | "IDS_ARCHIVED": "Archived", 17 | "IDS_STATUS": "Status", 18 | "IDS_REASSIGN": "Re-assign", 19 | "IDS_UNASSIGN": "Un-assign", 20 | "IDS_OPEN": "Open", 21 | "IDS_TASK_STATUS": "Task status", 22 | "IDS_TASK_LABELERS": "Labelers", 23 | "IDS_ALL": "All", 24 | "IDS_NEW": "New", 25 | "IDS_DOING": "Doing", 26 | "IDS_COMPLETED": "Completed", 27 | "IDS_ASSIGNED": "Assigned", 28 | "IDS_UNASSIGNED": "Unassigned", 29 | "IDS_NAME": "Name", 30 | "IDS_DESCRIPTION": "Description", 31 | "IDS_KEY": "Key", 32 | "IDS_CREATE": "Create", 33 | "IDS_SINGLE": "Single", 34 | "IDS_TRIANGLE": "Triangle", 35 | "IDS_WORKFLOW_HELP_DES_1": "Single (default): A single study required at least 1 person to read.", 36 | "IDS_WORKFLOW_HELP_DES_2": "Triangle: A single study required at at least 2 annotators and 1 reviewer.", 37 | "IDS_SCOPE": "Scope", 38 | "IDS_ANNOTATION_TYPE": "Annotation type", 39 | "IDS_SHORT_NAME": "Short Name", 40 | "IDS_FAMILY_TYPE": "Family type", 41 | "IDS_MULTIPLE_CHOICES": "Multiple choices", 42 | "IDS_SINGLE_CHOICES": "Single choice", 43 | "IDS_PARENT_LABEL": "Parent Label", 44 | "IDS_CANCEL": "Cancel", 45 | "IDS_UPDATE": "Update", 46 | "IDS_EDIT_LABEL": "Edit label", 47 | "IDS_NOT_ASSIGNED": "Not Assigned", 48 | "IDS_STUDY_UID": "Study UID", 49 | "IDS_DELETE": "Delete", 50 | "IDS_ASSIGN": "Assign", 51 | "IDS_ASSIGN_LABELER": "Assign labeler", 52 | "IDS_IMPORT_DATA": "Import Data", 53 | "IDS_EXPORT_LABEL": "Export Label", 54 | "IDS_STUDY_STATUS": "Study status", 55 | "IDS_LABELS": "Labels", 56 | "IDS_CONFIRM_DELETE_STUDY_MSG": "Are you sure delete this studies?", 57 | "IDS_NO": "No", 58 | "IDS_DELETE_PROJECT": "Delete this project", 59 | "IDS_USER_MANAGEMENT": "User management", 60 | "IDS_ANNOTATOR": "Annotator", 61 | "IDS_REVIEWER": "Reviewer", 62 | "IDS_PROJECT_OWNER": "Project Owner", 63 | "IDS_SAVE": "Save", 64 | "IDS_PROJECT_NAME": "Project Name", 65 | "IDS_LABELING_INSTRUCTION": "Labeling Instruction", 66 | "IDS_WORKFLOW": "Workflow", 67 | "IDS_DATA": "Data", 68 | "IDS_TASKS": "Tasks", 69 | "IDS_SETTING": "Setting", 70 | "IDS_ANNOTATE": "Annotate", 71 | "IDS_REVIEW": "Review", 72 | "IDS_AS_MANAGER": "As manager", 73 | "IDS_AS_ANNOTATOR": "As annotator", 74 | "IDS_AS_REVIEWER": "As reviewer", 75 | "IDS_PROJECTS": "Projects", 76 | "IDS_MANAGER": "Manager", 77 | "IDS_LABELER": "Labeler", 78 | "IDS_COMPLETE": "Complete", 79 | "IDS_STUDIES_LOWER_CASE": "studies", 80 | "IDS_DATE": "Date", 81 | "IDS_VERSION_TAG": "Version tag", 82 | "IDS_ACTION": "Action", 83 | "IDS_DOWNLOAD": "Download", 84 | "IDS_EXPORT": "Export", 85 | "IDS_EXPORTED_VERSIONS": "Exported versions", 86 | "IDS_EXPORT_NEW_VERSION": "Export new version", 87 | "IDS_TAG": "Tag", 88 | "IDS_DRAG_BOX_UPLOAD_TITLE": "Click or drag file to this area to upload", 89 | "IDS_DRAG_BOX_UPLOAD_DESCRIPTION": "Support for a single or bulk upload. Strictly prohibit from uploading company data or other band files", 90 | "IDS_SELECT_FILE": "Select File", 91 | "IDS_SELECT_FOLDER": "Select Folder", 92 | "IDS_START_UPLOAD": "Start Upload", 93 | "IDS_STOP": "Stop", 94 | "IDS_CLOSE": "Close", 95 | "IDS_RETRY": "Retry", 96 | "IDS_EXPORT_LOG": "Export log", 97 | "IDS_MINIMIZE": "Minimize", 98 | "IDS_LABEL_GROUPS": "Label groups", 99 | "IDS_ADD": "Add", 100 | "IDS_ENTER_GROUP_NAME": "Enter group name", 101 | "IDS_PLEASE_ENTER_NAME": "Please enter name", 102 | "IDS_NEW_LABEL": "New Label", 103 | "IDS_FINDING": "Finding", 104 | "IDS_IMPRESSION": "Impression", 105 | "IDS_LABELING_TYPE": "Labeling type" 106 | } -------------------------------------------------------------------------------- /src/utils/service/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import cookie from 'js-cookie'; 3 | import Qs from 'qs'; 4 | import { message } from 'antd'; 5 | import { TOKEN, REFRESH_TOKEN, CONFIG_SERVER } from '../constants/config'; 6 | import { 7 | actionRefreshToken, 8 | actionLogout 9 | } from '../../view/system/systemAction'; 10 | 11 | const request = axios.create(); 12 | 13 | let isAlreadyFetchingAccessToken = false; 14 | let subscribers = []; 15 | const tokenUrl = '/token'; 16 | 17 | function onAccessTokenFetched(access_token) { 18 | subscribers = subscribers.map((callback) => callback(access_token)); 19 | subscribers = []; 20 | } 21 | 22 | function addSubscriber(callback) { 23 | subscribers.push(callback); 24 | } 25 | 26 | request.interceptors.request.use( 27 | (config) => { 28 | // if (config.url.indexOf(tokenUrl) !== -1) { 29 | // delete config.headers.Authorization; 30 | // } 31 | return config; 32 | }, 33 | (error) => { 34 | return Promise.reject(error.response || { data: {} }); 35 | } 36 | ); 37 | 38 | request.interceptors.response.use( 39 | (response) => { 40 | return response; 41 | }, 42 | (error) => { 43 | console.log('VANHT-debug-401', error); 44 | const originalRequest = error.config; 45 | if ( 46 | (error.response && error.response.status === 401) || 47 | !cookie.get(TOKEN) 48 | ) { 49 | const refreshToken = cookie.get(REFRESH_TOKEN); 50 | if ( 51 | refreshToken && 52 | !originalRequest._retry && 53 | error.config.url.indexOf(tokenUrl) === -1 54 | ) { 55 | originalRequest._retry = true; 56 | if (!isAlreadyFetchingAccessToken) { 57 | isAlreadyFetchingAccessToken = true; 58 | actionRefreshToken(refreshToken) 59 | .then((res) => { 60 | isAlreadyFetchingAccessToken = false; 61 | cookie.set(TOKEN, res.data.access_token, { 62 | expires: new Date( 63 | (res.data.expires_in || 1800) * 1000 + Date.now() 64 | ), 65 | }); 66 | cookie.set(REFRESH_TOKEN, res.data.refresh_token, { 67 | expires: new Date( 68 | (res.data.refresh_expires_in || 1800) * 1000 + Date.now() 69 | ), 70 | }); 71 | onAccessTokenFetched(res.data.access_token); 72 | }) 73 | .catch(() => { 74 | subscribers = []; 75 | cookie.remove(TOKEN); 76 | cookie.remove(REFRESH_TOKEN); 77 | actionLogout(); 78 | }); 79 | } 80 | const retryOriginalRequest = new Promise((resolve) => { 81 | addSubscriber((access_token) => { 82 | originalRequest.headers.Authorization = 'Bearer ' + access_token; 83 | resolve(axios(originalRequest)); 84 | }); 85 | }); 86 | return retryOriginalRequest; 87 | } else { 88 | if (error.config.url.indexOf(tokenUrl) !== -1) { 89 | if (error.response.status === 403) { 90 | message.error('Your account is not allowed to access the system!'); 91 | setTimeout(() => { 92 | actionLogout(); 93 | }, 1000); 94 | } else if (error.response.status === 400) { 95 | message.error('Sysstem error!'); 96 | } 97 | } else { 98 | actionLogout(); 99 | } 100 | subscribers = []; 101 | cookie.remove(TOKEN); 102 | cookie.remove(REFRESH_TOKEN); 103 | } 104 | } else { 105 | return Promise.reject(error?.response || { data: {} }); 106 | } 107 | } 108 | ); 109 | 110 | const api = (options) => { 111 | let config = { 112 | baseURL: CONFIG_SERVER.BASE_URL, 113 | ...options, 114 | paramsSerializer: (params) => 115 | Qs.stringify(params, { arrayFormat: 'repeat' }), 116 | headers: { 117 | ...options.headers, 118 | }, 119 | }; 120 | if (cookie.get(TOKEN)) { 121 | config.headers.Authorization = `Bearer ${cookie.get(TOKEN)}`; 122 | } 123 | return request(config); 124 | }; 125 | 126 | export default api; 127 | -------------------------------------------------------------------------------- /src/utils/locale/vi.json: -------------------------------------------------------------------------------- 1 | { 2 | "IDS_COMMON_CHANGE_LANGUAGE": "Thay đổi ngôn ngữ", 3 | "IDS_COMMON_LOGIN": "Đăng nhập", 4 | "IDS_COMMON_LOGOUT": "Đăng xuất", 5 | "IDS_CREATE_PROJECT": "Create project", 6 | "IDS_MY_PROJECTS": "Dự án", 7 | "IDS_RECENT": "Gần đây", 8 | "IDS_LABEL_MANAGEMENT": "Quản lý nhãn", 9 | "IDS_SEARCH": "Tìm kiếm", 10 | "IDS_TASK_CODE": "Mã công việc", 11 | "IDS_ASSIGNEE": "Assignee", 12 | "IDS_TYPE": "Loại", 13 | "IDS_STUDY_CODE": "Study code", 14 | "IDS_LAST_OPEN": "Last open", 15 | "IDS_LAST_ACTIVITY": "Last activity", 16 | "IDS_ARCHIVED": "Archived", 17 | "IDS_STATUS": "Trạng thái", 18 | "IDS_REASSIGN": "Re-assign", 19 | "IDS_UNASSIGN": "Un-assign", 20 | "IDS_OPEN": "Mở", 21 | "IDS_TASK_STATUS": "Trạng thái công việc", 22 | "IDS_TASK_LABELERS": "Labelers", 23 | "IDS_ALL": "Tất cả", 24 | "IDS_NEW": "Mới", 25 | "IDS_DOING": "Đang làm", 26 | "IDS_COMPLETED": "Đã hoàn thành", 27 | "IDS_ASSIGNED": "Assigned", 28 | "IDS_UNASSIGNED": "Unassigned", 29 | "IDS_NAME": "Name", 30 | "IDS_DESCRIPTION": "Description", 31 | "IDS_KEY": "Key", 32 | "IDS_CREATE": "Create", 33 | "IDS_SINGLE": "Single", 34 | "IDS_TRIANGLE": "Triangle", 35 | "IDS_WORKFLOW_HELP_DES_1": "Single (default): A single study required at least 1 person to read.", 36 | "IDS_WORKFLOW_HELP_DES_2": "Triangle: A single study required at at least 2 annotators and 1 reviewer.", 37 | "IDS_SCOPE": "Scope", 38 | "IDS_ANNOTATION_TYPE": "Annotation type", 39 | "IDS_SHORT_NAME": "Short Name", 40 | "IDS_FAMILY_TYPE": "Family type", 41 | "IDS_MULTIPLE_CHOICES": "Multiple choices", 42 | "IDS_SINGLE_CHOICES": "Single choice", 43 | "IDS_PARENT_LABEL": "Parent Label", 44 | "IDS_CANCEL": "Cancel", 45 | "IDS_UPDATE": "Update", 46 | "IDS_EDIT_LABEL": "Edit label", 47 | "IDS_NOT_ASSIGNED": "Not Assigned", 48 | "IDS_STUDY_UID": "Study UID", 49 | "IDS_DELETE": "Xóa", 50 | "IDS_ASSIGN": "Assign", 51 | "IDS_ASSIGN_LABELER": "Assign labeler", 52 | "IDS_IMPORT_DATA": "Nhập dữ liệu", 53 | "IDS_EXPORT_LABEL": "Xuất nhãn", 54 | "IDS_STUDY_STATUS": "Study status", 55 | "IDS_LABELS": "Labels", 56 | "IDS_CONFIRM_DELETE_STUDY_MSG": "Are you sure delete this studies?", 57 | "IDS_NO": "Không", 58 | "IDS_DELETE_PROJECT": "Xóa dự án", 59 | "IDS_USER_MANAGEMENT": "User management", 60 | "IDS_ANNOTATOR": "Annotator", 61 | "IDS_REVIEWER": "Reviewer", 62 | "IDS_PROJECT_OWNER": "Project Owner", 63 | "IDS_SAVE": "Lưu", 64 | "IDS_PROJECT_NAME": "Tên dự án", 65 | "IDS_LABELING_INSTRUCTION": "Labeling Instruction", 66 | "IDS_WORKFLOW": "Workflow", 67 | "IDS_DATA": "Dữ liệu", 68 | "IDS_TASKS": "Công việc", 69 | "IDS_SETTING": "Cài đặt", 70 | "IDS_ANNOTATE": "Annotate", 71 | "IDS_REVIEW": "Review", 72 | "IDS_AS_MANAGER": "As manager", 73 | "IDS_AS_ANNOTATOR": "As annotator", 74 | "IDS_AS_REVIEWER": "As reviewer", 75 | "IDS_PROJECTS": "Dự án", 76 | "IDS_MANAGER": "Manager", 77 | "IDS_LABELER": "Labeler", 78 | "IDS_COMPLETE": "Hoàn thành", 79 | "IDS_STUDIES_LOWER_CASE": "studies", 80 | "IDS_DATE": "Ngày", 81 | "IDS_VERSION_TAG": "Version tag", 82 | "IDS_ACTION": "Action", 83 | "IDS_DOWNLOAD": "Download", 84 | "IDS_EXPORT": "Export", 85 | "IDS_EXPORTED_VERSIONS": "Exported versions", 86 | "IDS_EXPORT_NEW_VERSION": "Export new version", 87 | "IDS_TAG": "Tag", 88 | "IDS_DRAG_BOX_UPLOAD_TITLE": "Click or drag file to this area to upload", 89 | "IDS_DRAG_BOX_UPLOAD_DESCRIPTION": "Support for a single or bulk upload. Strictly prohibit from uploading company data or other band files", 90 | "IDS_SELECT_FILE": "Chọn File", 91 | "IDS_SELECT_FOLDER": "Chọn thư mục", 92 | "IDS_START_UPLOAD": "Bắt đầu Upload", 93 | "IDS_STOP": "Dừng", 94 | "IDS_CLOSE": "Đóng", 95 | "IDS_RETRY": "Thử lại", 96 | "IDS_EXPORT_LOG": "Export log", 97 | "IDS_MINIMIZE": "Thu nhỏ", 98 | "IDS_LABEL_GROUPS": "Label groups", 99 | "IDS_ADD": "Thêm", 100 | "IDS_ENTER_GROUP_NAME": "Enter group name", 101 | "IDS_PLEASE_ENTER_NAME": "Please enter name", 102 | "IDS_NEW_LABEL": "Nhãn mới", 103 | "IDS_FINDING": "Finding", 104 | "IDS_IMPRESSION": "Impression", 105 | "IDS_LABELING_TYPE": "Labeling type" 106 | } -------------------------------------------------------------------------------- /src/components/layout/header/Header.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { Menu, Dropdown, Layout, Modal, Avatar, Radio } from 'antd'; 3 | import { UserOutlined } from '@ant-design/icons'; 4 | import { connect } from 'react-redux'; 5 | import { FormattedMessage, useIntl } from 'react-intl'; 6 | import cookie from 'js-cookie'; 7 | import { withRouter } from 'react-router-dom'; 8 | import { 9 | routes, 10 | TOKEN, 11 | REFRESH_TOKEN, 12 | USER_ROLES, 13 | VINLAB_VIEW_MODE, 14 | } from '../../../utils/constants/config'; 15 | import { isEmpty } from '../../../utils/helpers'; 16 | import { vindr_lab_logo_white } from '../../../assets'; 17 | import { 18 | actionLogout, 19 | requestLogin, 20 | hasRolePO, 21 | actionChangeViewMode, 22 | } from '../../../view/system/systemAction'; 23 | import './Header.scss'; 24 | 25 | const Header = (props) => { 26 | const { formatMessage: t } = useIntl(); 27 | const { 28 | currentProject = {}, 29 | location = {}, 30 | account, 31 | viewMode, 32 | actionChangeViewMode, 33 | } = props; 34 | const { pathname = '' } = location; 35 | const isShowProjectName = pathname.indexOf(routes.STUDY_LIST) !== -1; 36 | 37 | useEffect(() => { 38 | if (!isEmpty(account)) { 39 | const previousViewMode = localStorage.getItem(VINLAB_VIEW_MODE); 40 | actionChangeViewMode( 41 | previousViewMode === USER_ROLES.PROJECT_OWNER || 42 | (!previousViewMode && hasRolePO(account)) 43 | ? USER_ROLES.PROJECT_OWNER 44 | : USER_ROLES.ANNOTATOR 45 | ); 46 | } 47 | // eslint-disable-next-line 48 | }, [account]); 49 | 50 | const handleClickAvatar = async (item) => { 51 | if (item.key === routes.LOGOUT) { 52 | Modal.confirm({ 53 | title: 'Are you sure?', 54 | content: null, 55 | onOk: () => { 56 | if (cookie.get(REFRESH_TOKEN)) { 57 | actionLogout(); 58 | } else { 59 | cookie.remove(TOKEN); 60 | cookie.remove(REFRESH_TOKEN); 61 | requestLogin(); 62 | } 63 | }, 64 | onCancel: () => {}, 65 | }); 66 | } 67 | }; 68 | 69 | const goHomePage = () => { 70 | props.history.push(routes.PROJECTS); 71 | }; 72 | 73 | const handleSwitchViewMode = (event) => { 74 | actionChangeViewMode(event?.target?.value); 75 | }; 76 | 77 | const menu = ( 78 | 79 | 80 | 81 | 82 | 83 | ); 84 | 85 | return ( 86 | 87 |
88 |
89 | 90 | 91 | 92 |
93 | {isShowProjectName && currentProject?.name && ( 94 |
{currentProject?.name}
95 | )} 96 |
97 |
98 | 104 | 105 | {t({ id: 'IDS_LABELER' })} 106 | 107 | 108 | {t({ id: 'IDS_MANAGER' })} 109 | 110 | 111 |
112 | 113 |
114 | {account?.preferred_username} 115 | } /> 116 |
117 |
118 |
119 |
120 |
121 | ); 122 | }; 123 | 124 | export default connect( 125 | (state) => ({ 126 | account: state.system.profile, 127 | viewMode: state.system.viewMode, 128 | currentProject: state.project.currentProject, 129 | }), 130 | { actionChangeViewMode } 131 | )(withRouter(Header)); 132 | -------------------------------------------------------------------------------- /src/components/labels/LabelGroups.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Button, Spin, Form, Input, message, Tooltip } from 'antd'; 4 | import { PlusCircleOutlined } from '@ant-design/icons'; 5 | import { useIntl } from 'react-intl'; 6 | import { 7 | actionGetLabelGroups, 8 | actionCreateLabelGroup, 9 | actionSetLabelGroups, 10 | } from './LabelsAction'; 11 | import './Labels.scss'; 12 | 13 | const LabelGroups = (props) => { 14 | const intl = useIntl(); 15 | const { formatMessage: t } = intl; 16 | const [form] = Form.useForm(); 17 | const [processing, setProcessing] = useState(false); 18 | const [isAdding, setAdding] = useState(false); 19 | 20 | const { 21 | labelGroups, 22 | isFetchingLabelGroups, 23 | selectedLabelGroup, 24 | actionSetLabelGroups, 25 | } = props; 26 | 27 | useEffect(() => { 28 | handleGetLabelGroups(); 29 | return () => { 30 | actionSetLabelGroups({}); 31 | }; 32 | // eslint-disable-next-line 33 | }, []); 34 | 35 | useEffect(() => { 36 | if ((labelGroups?.data || []).length > 0) { 37 | const firstLabelGroup = labelGroups?.data[0] || {}; 38 | const updateSelected = (labelGroups?.data || []).find( 39 | (lb) => lb.id === selectedLabelGroup.id 40 | ); 41 | actionSetLabelGroups(updateSelected || firstLabelGroup); 42 | } else { 43 | actionSetLabelGroups({}); 44 | } 45 | // eslint-disable-next-line 46 | }, [labelGroups]); 47 | 48 | const handleGetLabelGroups = () => { 49 | props.actionGetLabelGroups(); 50 | }; 51 | 52 | const handleAddLabelGroup = () => { 53 | if (processing) return; 54 | form 55 | .validateFields() 56 | .then(async (values) => { 57 | try { 58 | setProcessing(true); 59 | const { data } = await actionCreateLabelGroup({ 60 | name: values.name?.trim(), 61 | }); 62 | form.resetFields(['name']); 63 | handleGetLabelGroups(); 64 | setProcessing(false); 65 | actionSetLabelGroups(data?.data || {}); 66 | } catch (error) { 67 | message.error('Error'); 68 | setProcessing(false); 69 | } 70 | }) 71 | .catch((error) => {}); 72 | }; 73 | 74 | return ( 75 |
76 |
77 |
{t({ id: 'IDS_LABEL_GROUPS' })}
78 | 79 |
87 | 88 |
89 | {isAdding && ( 90 |
91 | 104 | handleAddLabelGroup()} 106 | enterButton={t({ id: 'IDS_ADD' })} 107 | placeholder={t({ id: 'IDS_ENTER_GROUP_NAME' })} 108 | autoComplete="off" 109 | className="input-add-lb-group" 110 | autoFocus 111 | /> 112 | 113 |
114 | )} 115 |
116 | {(labelGroups?.data || []).map((it) => ( 117 |
actionSetLabelGroups(it)} 123 | > 124 | {it.name} 125 |
126 | ))} 127 |
128 |
129 |
130 |
131 | ); 132 | }; 133 | 134 | export default connect( 135 | (state) => ({ 136 | labelGroups: state.label.labelGroups, 137 | isFetchingLabelGroups: state.label.isFetchingLabelGroups, 138 | selectedLabelGroup: state.label.selectedLabelGroup, 139 | }), 140 | { actionGetLabelGroups, actionSetLabelGroups } 141 | )(LabelGroups); 142 | -------------------------------------------------------------------------------- /src/components/uploadDatasets/UploadDatasets.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables.scss'; 2 | 3 | .import-data-content { 4 | .input-file { 5 | display: none; 6 | } 7 | .header-modal { 8 | .uploaded-info { 9 | margin-left: 8px; 10 | } 11 | } 12 | .progress-bar { 13 | padding: 0 24px 12px; 14 | background: $app-bg-box; 15 | border-radius: 0 0 10px 10px; 16 | .ant-progress-status-success { 17 | .ant-progress-bg { 18 | background-color: $app-primary-color; 19 | } 20 | } 21 | } 22 | .upload-container { 23 | height: 100%; 24 | display: flex; 25 | flex-direction: column; 26 | align-items: center; 27 | justify-content: center; 28 | padding: 24px; 29 | 30 | .upload-drag-box { 31 | max-width: 600px; 32 | min-width: 520px; 33 | height: 400px; 34 | margin-bottom: 20px; 35 | .drag-box { 36 | height: 100%; 37 | border-radius: $border-box-radius; 38 | position: relative; 39 | width: 100%; 40 | height: 100%; 41 | text-align: center; 42 | background: #fafafa; 43 | border: 1px dashed #d9d9d9; 44 | border-radius: 2px; 45 | cursor: pointer; 46 | -webkit-transition: border-color 0.3s; 47 | transition: border-color 0.3s; 48 | display: flex; 49 | align-items: center; 50 | 51 | .drag-content { 52 | p { 53 | margin: 0; 54 | } 55 | .ant-upload-drag-icon { 56 | margin-bottom: 20px; 57 | .anticon-upload { 58 | color: #38c789; 59 | font-size: 48px; 60 | } 61 | } 62 | .ant-upload-text { 63 | font-weight: 500; 64 | font-size: 16px; 65 | font-size: 16px; 66 | } 67 | .ant-upload-text, 68 | .ant-upload-hint { 69 | padding: 0 8px; 70 | } 71 | .ant-upload-hint { 72 | color: #d3d3d3; 73 | } 74 | } 75 | } 76 | 77 | .file-list { 78 | background: #fff; 79 | overflow: hidden; 80 | .ReactVirtualized__List { 81 | outline: none; 82 | } 83 | .file-item { 84 | color: #26292e; 85 | height: 35px; 86 | line-height: 35px; 87 | white-space: nowrap; 88 | margin-bottom: 8px; 89 | padding: 0 8px; 90 | display: flex; 91 | align-items: center; 92 | background: #f8f8f8; 93 | box-shadow: 1px 6px 15px rgba(0, 0, 0, 0.11); 94 | border-radius: 5px; 95 | 96 | .file-icon { 97 | margin-right: 4px; 98 | } 99 | .file-name { 100 | white-space: nowrap; 101 | overflow: hidden; 102 | text-overflow: ellipsis; 103 | padding: 0 4px; 104 | margin-right: 12px; 105 | } 106 | .file-status { 107 | margin-left: auto; 108 | background: #e8e8e8; 109 | width: 18px; 110 | height: 18px; 111 | display: flex; 112 | align-items: center; 113 | justify-content: center; 114 | border-radius: 50%; 115 | padding: 12px; 116 | color: #fff; 117 | cursor: pointer; 118 | .ic-uploading { 119 | font-size: 18px; 120 | color: $app-primary-color; 121 | } 122 | &.done { 123 | background: $app-primary-color; 124 | } 125 | &.error { 126 | background: #f9c200; 127 | } 128 | } 129 | } 130 | } 131 | 132 | &.hide-drag-box { 133 | .drag-box { 134 | display: none; 135 | } 136 | .file-list { 137 | border: 1px solid $defautl-font-color; 138 | height: 100%; 139 | border-radius: 8px; 140 | } 141 | } 142 | } 143 | 144 | .btn-action { 145 | padding: 12px 8px; 146 | .btn { 147 | min-width: 120px; 148 | min-height: 42px; 149 | border-radius: 4px; 150 | border: 1px solid $app-primary-color; 151 | color: $app-primary-color; 152 | box-sizing: border-box; 153 | 154 | &.btn-select-file { 155 | margin-right: 20px; 156 | } 157 | } 158 | 159 | .btn-primary { 160 | color: #fff; 161 | box-shadow: 6px 16px 20px rgba(0, 0, 0, 0.06); 162 | } 163 | 164 | .btn-stop { 165 | color: #fff; 166 | margin-right: 20px; 167 | border-color: #ff4d4f; 168 | } 169 | 170 | .btn-export-log { 171 | margin-right: 20px; 172 | } 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/components/labels/Labels.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables.scss'; 2 | 3 | .create-label-modal { 4 | .ant-form { 5 | .ant-form-item { 6 | flex-direction: row !important; 7 | } 8 | } 9 | 10 | .btn-delete-label { 11 | border-color: #ff4d4f !important; 12 | } 13 | 14 | .btn-pick-color { 15 | height: 22px; 16 | width: 22px; 17 | min-width: unset; 18 | border: none; 19 | padding: 0; 20 | } 21 | } 22 | 23 | .label-groups { 24 | padding: 8px; 25 | height: 100%; 26 | .header-label-groups { 27 | display: flex; 28 | align-items: center; 29 | margin-bottom: 20px; 30 | .lb-title { 31 | font-size: 16px; 32 | } 33 | .btn-add-label-group { 34 | margin-left: auto; 35 | } 36 | } 37 | 38 | .label-groups-content { 39 | .form-lb-group { 40 | .ant-input-search { 41 | background: transparent; 42 | .ant-input { 43 | background: transparent; 44 | color: $defautl-font-color; 45 | height: 32px; 46 | } 47 | .ant-input-group-addon { 48 | background: transparent; 49 | .ant-input-search-button { 50 | border: none; 51 | height: 32px; 52 | } 53 | } 54 | } 55 | .ant-form-item:not(.ant-form-item-has-error) { 56 | .ant-form-item-control { 57 | .ant-input { 58 | border-color: $app-box-border-color; 59 | &:focus, 60 | &:hover { 61 | border-color: $app-primary-color; 62 | } 63 | } 64 | } 65 | } 66 | } 67 | .lb-group-list { 68 | .lb-group-item { 69 | padding: 12px 8px; 70 | cursor: pointer; 71 | white-space: nowrap; 72 | text-overflow: ellipsis; 73 | overflow: hidden; 74 | &.active-item { 75 | background-color: $app-bg-box; 76 | } 77 | } 78 | } 79 | } 80 | } 81 | 82 | .labels-wrapper { 83 | background: $app-bg-box; 84 | border-radius: $border-box-radius; 85 | padding: 8px; 86 | height: 100%; 87 | width: 100%; 88 | .header-labels { 89 | display: flex; 90 | align-items: center; 91 | margin-bottom: 20px; 92 | .group-info { 93 | display: flex; 94 | align-items: center; 95 | .lb-title { 96 | margin-right: 10px; 97 | color: $defautl-font-color; 98 | } 99 | } 100 | .lb-title { 101 | font-size: 16px; 102 | } 103 | .btn-add-label { 104 | margin-left: auto; 105 | } 106 | } 107 | 108 | .labels-content { 109 | .txt-title { 110 | font-size: 16px; 111 | margin-bottom: 10px; 112 | } 113 | .finding-wrapper { 114 | margin-bottom: 25px; 115 | } 116 | .label-table { 117 | .ant-table-wrapper { 118 | border-radius: 5px; 119 | overflow: hidden; 120 | .ant-table-container { 121 | background: #3f444f !important; 122 | } 123 | } 124 | .ant-table-thead { 125 | background: red; 126 | tr { 127 | th { 128 | background: #3f444f !important; 129 | border-right: 2px solid #303237 !important; 130 | &:last-child { 131 | border-right: none !important; 132 | } 133 | } 134 | } 135 | } 136 | .ant-table-tbody { 137 | background: #1e2025; 138 | tr { 139 | td { 140 | vertical-align: top; 141 | } 142 | &:hover { 143 | td { 144 | background: rgba(76, 78, 87, 0.4) !important; 145 | } 146 | } 147 | } 148 | } 149 | .lb-list { 150 | .lb-wrap-item { 151 | white-space: nowrap; 152 | overflow: hidden; 153 | text-overflow: ellipsis; 154 | } 155 | .lb-color { 156 | width: 10px; 157 | height: 10px; 158 | display: inline-block; 159 | margin-right: 12px; 160 | } 161 | 162 | .lb-item { 163 | padding: 3px; 164 | span { 165 | cursor: pointer; 166 | } 167 | } 168 | 169 | .sub-labels { 170 | padding-left: 18px; 171 | margin-bottom: 4px; 172 | .sub-label-item { 173 | padding: 3px; 174 | list-style: none; 175 | } 176 | } 177 | } 178 | } 179 | } 180 | 181 | .select-managers { 182 | display: flex; 183 | align-items: center; 184 | margin-bottom: 16px; 185 | .lb-title { 186 | font-size: 16px; 187 | color: #d1d1d7; 188 | } 189 | .ant-select-selector { 190 | background: #26292e; 191 | color: #d1d1d7; 192 | border: 1px solid #53555a; 193 | .ant-select-selection-item { 194 | background: #363a42; 195 | border: none; 196 | .ant-select-selection-item-remove { 197 | color: #d1d1d7; 198 | } 199 | } 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/view/studyList/setting/Setting.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variables.scss'; 2 | 3 | .setting-page { 4 | display: flex; 5 | flex-direction: column; 6 | flex: 1; 7 | 8 | .header-action { 9 | display: flex; 10 | position: absolute; 11 | top: 10px !important; 12 | right: 36px; 13 | width: unset !important; 14 | .btn-update-project { 15 | .btn { 16 | min-width: 120px; 17 | &:disabled { 18 | color: rgba(209, 209, 215, 0.6); 19 | } 20 | } 21 | } 22 | } 23 | 24 | .setting-container { 25 | display: flex; 26 | flex-direction: column; 27 | flex: 1; 28 | padding: 25px 30px; 29 | 30 | .form-update-project { 31 | width: 100%; 32 | position: relative; 33 | 34 | .workflow-item { 35 | .anticon-question-circle { 36 | color: #fff; 37 | } 38 | } 39 | 40 | .delete-project { 41 | position: absolute; 42 | right: 0; 43 | } 44 | 45 | .session-title { 46 | margin-bottom: 8px; 47 | color: $defautl-font-color; 48 | } 49 | .ant-form-item-label { 50 | label { 51 | color: $defautl-font-color; 52 | min-width: 160px; 53 | } 54 | } 55 | 56 | .ant-form-item-control { 57 | max-width: 350px; 58 | .ant-input, 59 | .ant-select-selector { 60 | background: $app-bg-page-content; 61 | color: $defautl-font-color; 62 | } 63 | .ant-select-arrow { 64 | color: $defautl-font-color; 65 | } 66 | } 67 | 68 | .ant-form-item:not(.ant-form-item-has-error) { 69 | .ant-form-item-control { 70 | .ant-input { 71 | border-color: $app-box-border-color; 72 | &:focus, 73 | &:hover { 74 | border-color: $app-primary-color; 75 | } 76 | } 77 | .ant-select:not(.ant-select-focused) { 78 | .ant-select-selector { 79 | border-color: $app-box-border-color; 80 | } 81 | } 82 | .ant-select:not(.ant-select-disabled):hover { 83 | .ant-select-selector { 84 | border-color: $app-primary-color; 85 | } 86 | } 87 | } 88 | } 89 | 90 | .assign-label-wrapper { 91 | margin-bottom: 24px; 92 | .select-label-group { 93 | display: flex; 94 | .form-item-lb-group { 95 | width: 250px; 96 | } 97 | .btn-add-label-group { 98 | margin-left: 8px; 99 | .anticon-plus-circle { 100 | font-size: 22px; 101 | } 102 | } 103 | } 104 | 105 | .labels-wrapper { 106 | background: transparent; 107 | border: 1px solid $app-box-border-color; 108 | padding: 20px; 109 | .labels-content { 110 | .txt-title { 111 | color: $defautl-font-color; 112 | } 113 | } 114 | } 115 | } 116 | 117 | .assign-user-wrapper { 118 | .lb-input { 119 | color: $defautl-font-color; 120 | margin-bottom: 5px; 121 | text-transform: uppercase; 122 | } 123 | .select-users { 124 | padding: 8px 0; 125 | .ant-form-item-control { 126 | min-height: 350px; 127 | background: $app-bg-box; 128 | border-radius: $border-box-radius; 129 | padding: 4px; 130 | cursor: default; 131 | .ant-select-selector { 132 | display: flex; 133 | flex-wrap: wrap-reverse; 134 | background: $app-bg-box; 135 | .ant-select-selection-item { 136 | width: 100%; 137 | background: #363a42; 138 | border: none; 139 | color: $defautl-font-color; 140 | padding: 8px; 141 | height: auto; 142 | display: flex; 143 | justify-content: space-between; 144 | margin-top: 10px; 145 | border-radius: $border-box-radius; 146 | .ant-select-selection-item-remove { 147 | width: 22px; 148 | height: 22px; 149 | border-radius: 50%; 150 | background: #dadada; 151 | } 152 | } 153 | 154 | .ant-select-selection-search { 155 | margin: 10px 0 0 0; 156 | width: 100% !important; 157 | border: 1px solid $app-box-border-color; 158 | border-radius: $border-box-radius; 159 | padding: 5px; 160 | .ant-select-selection-search-input { 161 | opacity: 1 !important; 162 | margin: 0 !important; 163 | } 164 | } 165 | } 166 | } 167 | } 168 | } 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/view/studyList/StudyListAction.js: -------------------------------------------------------------------------------- 1 | import api from '../../utils/service/api'; 2 | import * as actionType from '../../utils/constants/actions'; 3 | 4 | export const actionGetStudies = (params = {}) => async (dispatch) => { 5 | try { 6 | dispatch({ type: actionType.FETCHING_STUDIES, payload: true }); 7 | const { data } = await api({ 8 | method: 'get', 9 | url: '/api/studies', 10 | params, 11 | }); 12 | dispatch({ type: actionType.FETCH_STUDIES, payload: data }); 13 | } catch (error) { 14 | dispatch({ type: actionType.FETCHING_STUDIES, payload: false }); 15 | } 16 | }; 17 | 18 | export const actionGetTasks = (params = {}) => async (dispatch) => { 19 | try { 20 | dispatch({ type: actionType.FETCHING_TASKS, payload: true }); 21 | const { data } = await api({ 22 | method: 'get', 23 | url: '/api/tasks', 24 | params, 25 | }); 26 | dispatch({ type: actionType.FETCH_TASKS, payload: data }); 27 | } catch (error) { 28 | dispatch({ type: actionType.FETCHING_TASKS, payload: false }); 29 | } 30 | }; 31 | 32 | export const getTotalStatusTask = (params = {}) => { 33 | return api({ 34 | method: 'get', 35 | url: '/api/tasks', 36 | params: { 37 | _offset: 0, 38 | _limit: 0, 39 | _agg: ['status', 'assignee_id'], 40 | ...params, 41 | }, 42 | }); 43 | }; 44 | 45 | export const actionGetTotalStatus = (params = {}) => async (dispatch) => { 46 | try { 47 | const { data } = await api({ 48 | method: 'get', 49 | url: '/api/studies', 50 | params: { _offset: 0, _limit: 0, _agg: 'status', ...params }, 51 | }); 52 | dispatch({ type: actionType.FETCH_STATS_STUDIES, payload: data || {} }); 53 | } catch (error) { 54 | dispatch({ type: actionType.FETCH_STATS_STUDIES, payload: {} }); 55 | } 56 | }; 57 | 58 | export const actionUploadDICOM = (data = {}) => { 59 | return api({ 60 | method: 'post', 61 | url: '/api/studies/upload', 62 | data, 63 | }); 64 | }; 65 | 66 | export const actionGetExportedVersions = (params = {}) => async (dispatch) => { 67 | try { 68 | const { data } = await api({ 69 | method: 'get', 70 | url: '/api/stats/label_exports', 71 | params, 72 | }); 73 | dispatch({ type: actionType.FETCH_EXPORTED_VERSIONS, payload: data }); 74 | } catch (error) { 75 | console.log(error); 76 | } 77 | }; 78 | 79 | export const actionExportLabel = (data = {}) => { 80 | return api({ 81 | method: 'post', 82 | url: '/api/stats/label_exports', 83 | data, 84 | }); 85 | }; 86 | 87 | export const actionDeleteStudy = (studyId) => { 88 | return api({ 89 | method: 'delete', 90 | url: '/api/studies/' + studyId, 91 | }); 92 | }; 93 | 94 | export const actionDeleteStudies = (ids = []) => { 95 | return api({ 96 | method: 'post', 97 | url: '/api/studies/delete_many', 98 | data: { 99 | ids, 100 | }, 101 | }); 102 | }; 103 | 104 | export const actionCreateSession = (data = {}) => { 105 | return api({ 106 | method: 'post', 107 | url: '/api/sessions', 108 | data, 109 | }); 110 | }; 111 | 112 | export const actionAssignTaskCondition = (data = {}) => { 113 | return api({ 114 | method: 'post', 115 | url: '/api/tasks/assign', 116 | data, 117 | }); 118 | }; 119 | 120 | export const actionAssignTask = (data = {}) => { 121 | return api({ 122 | method: 'post', 123 | url: '/api/tasks', 124 | data, 125 | }); 126 | }; 127 | 128 | export const actionUpdateTask = (taskId = '', data = {}) => { 129 | return api({ 130 | method: 'put', 131 | url: '/api/tasks/' + taskId + '/status', 132 | data, 133 | }); 134 | }; 135 | 136 | export const actionReassignTasks = (data = {}) => { 137 | return api({ 138 | method: 'post', 139 | url: '/api/tasks/update_status_many', 140 | data, 141 | }); 142 | }; 143 | 144 | export const actionDeleteTasks = (taskIds = []) => { 145 | return api({ 146 | method: 'post', 147 | url: '/api/tasks/delete_many', 148 | data: { 149 | ids: taskIds, 150 | }, 151 | }); 152 | }; 153 | 154 | export const actionDownloadLabel = async (downloadUrl) => { 155 | try { 156 | const res = await api({ 157 | method: 'get', 158 | url: downloadUrl, 159 | responseType: 'arraybuffer', 160 | headers: { 161 | 'Content-Type': 'application/x-zip-compressed; charset=utf-8', 162 | }, 163 | }); 164 | 165 | const contentDisposition = res.headers['content-disposition'] || ''; 166 | const fileName = contentDisposition.split('=').pop() || 'export_label.json'; 167 | downloadFile(fileName, res.data); 168 | } catch (error) { 169 | console.log(error); 170 | } 171 | }; 172 | 173 | export const downloadFile = (fileName, data) => { 174 | let anchor = document.createElement('a'); 175 | const blob = new Blob([data]); 176 | let objectUrl = window.URL.createObjectURL(blob); 177 | anchor.href = objectUrl; 178 | anchor.download = fileName; 179 | anchor.click(); 180 | window.URL.revokeObjectURL(objectUrl); 181 | }; 182 | -------------------------------------------------------------------------------- /src/view/project/CreateProjectModal.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Form, Modal, message, Input, Spin, Select } from 'antd'; 3 | import { useIntl } from 'react-intl'; 4 | import { WORKFLOW_PROJECT } from '../../utils/constants/config'; 5 | import { actionCreateProject } from './ProjectAction'; 6 | 7 | const CreateProjectModal = (props) => { 8 | const { visible = true, onCancel, onOk } = props; 9 | const [processing, setProcessing] = useState(false); 10 | const [form] = Form.useForm(); 11 | const intl = useIntl(); 12 | const { formatMessage: t } = intl; 13 | 14 | const getProjectKey = (projectName = '') => { 15 | const words = projectName.split(' '); 16 | let chars = ''; 17 | (words || []).some((it) => { 18 | let word = it?.trim() || ''; 19 | if (chars.length === 5) return true; 20 | if (word) { 21 | for (let i = 0; i < word.length; i++) { 22 | if (word.substr(i, 1).match(/^[a-zA-Z]$/)) { 23 | chars += word.substr(i, 1).toUpperCase(); 24 | break; 25 | } 26 | } 27 | } 28 | return false; 29 | }); 30 | form.setFieldsValue({ key: chars }); 31 | }; 32 | 33 | const handleOk = (event) => { 34 | if (processing) return; 35 | event.stopPropagation(); 36 | form 37 | .validateFields() 38 | .then(async (values) => { 39 | try { 40 | setProcessing(true); 41 | const dataDTO = { 42 | name: values?.name?.trim(), 43 | description: values?.description?.trim(), 44 | workflow: values?.workflow, 45 | labeling_type: values?.labeling_type || '2D', 46 | }; 47 | if (values.key?.trim()) { 48 | dataDTO.key = values.key?.trim(); 49 | } 50 | 51 | const { data } = await actionCreateProject(dataDTO); 52 | setProcessing(false); 53 | if (onOk) onOk(data); 54 | } catch (error) { 55 | message.error('Error'); 56 | setProcessing(false); 57 | } 58 | }) 59 | .catch((error) => {}); 60 | }; 61 | 62 | const handleCancel = () => { 63 | if (processing) return; 64 | if (onCancel) onCancel(); 65 | }; 66 | 67 | return ( 68 | 79 | 80 |
81 |
88 | 101 | 103 | getProjectKey(event?.target?.value?.trim()) 104 | } 105 | /> 106 | 107 | 112 | 113 | 114 | {t({ id: 'IDS_WORKFLOW' })}} 117 | rules={[{ required: true }]} 118 | labelAlign="left" 119 | initialValue={WORKFLOW_PROJECT.SINGLE} 120 | tooltip={{ 121 | title: ( 122 | 123 |
{t({ id: 'IDS_WORKFLOW_HELP_DES_1' })}
124 |
{t({ id: 'IDS_WORKFLOW_HELP_DES_2' })}
125 |
126 | ), 127 | overlayClassName: 'tooltip-workflow-help', 128 | placement: 'right', 129 | }} 130 | > 131 | 139 |
140 | {t({ id: 'IDS_LABELING_TYPE' })}} 143 | initialValue="2D" 144 | > 145 | 149 | 150 | 155 | 156 | 157 |
158 |
159 |
160 |
161 | ); 162 | }; 163 | 164 | export default CreateProjectModal; 165 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/view/studyList/StudyList.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { withRouter } from 'react-router-dom'; 4 | import Qs from 'qs'; 5 | import { Button } from 'antd'; 6 | import { QuestionOutlined } from '@ant-design/icons'; 7 | import { useIntl } from 'react-intl'; 8 | import Data from './data/Data'; 9 | import Task from './task/Task'; 10 | import Setting from './setting/Setting'; 11 | import { 12 | actionGetProjectDetail, 13 | actionSetProjectDetail, 14 | } from '../project/ProjectAction'; 15 | import { STUDY_TABS, USER_ROLES, routes } from '../../utils/constants/config'; 16 | import { isEmpty } from '../../utils/helpers'; 17 | import { isProjectOwner } from '../system/systemAction'; 18 | import './StudyList.scss'; 19 | 20 | const StudyList = (props) => { 21 | const { formatMessage: t } = useIntl(); 22 | const [selectedTab, setSelectedTab] = useState({}); 23 | 24 | const { 25 | match = {}, 26 | userInfo, 27 | location = {}, 28 | currentProject, 29 | viewMode, 30 | } = props; 31 | const urlParams = Qs.parse(location.search, { ignoreQueryPrefix: true }); 32 | 33 | const projectId = match.params?.projectId; 34 | const isPO = isProjectOwner(currentProject, userInfo); 35 | const isViewAsPO = viewMode === USER_ROLES.PROJECT_OWNER; 36 | 37 | const tabList = [ 38 | { key: STUDY_TABS.DATA, name: t({ id: 'IDS_DATA' }), isPO: true }, 39 | { key: STUDY_TABS.TASK, name: t({ id: 'IDS_TASKS' }), isPO: true }, 40 | { key: STUDY_TABS.SETTING, name: t({ id: 'IDS_SETTING' }), isPO: true }, 41 | { key: STUDY_TABS.ANNOTATE, name: t({ id: 'IDS_ANNOTATE' }) }, 42 | { key: STUDY_TABS.REVIEW, name: t({ id: 'IDS_REVIEW' }) }, 43 | ]; 44 | 45 | useEffect(() => { 46 | if (projectId) { 47 | props.actionGetProjectDetail(projectId); 48 | } 49 | return () => { 50 | props.actionSetProjectDetail({}); 51 | }; 52 | // eslint-disable-next-line 53 | }, [projectId]); 54 | 55 | useEffect(() => { 56 | if (!isEmpty(userInfo) && viewMode) { 57 | if (isViewAsPO) { 58 | if (urlParams?.tab === STUDY_TABS.SETTING.toLocaleLowerCase()) { 59 | handleReplaceState(tabList[2].key); 60 | setSelectedTab(tabList[2]); 61 | } else if (urlParams?.tab === STUDY_TABS.TASK.toLocaleLowerCase()) { 62 | handleReplaceState(tabList[1].key); 63 | setSelectedTab(tabList[1]); 64 | } else { 65 | handleReplaceState(tabList[0].key); 66 | setSelectedTab(tabList[0]); 67 | } 68 | } else { 69 | if (urlParams?.tab === STUDY_TABS.REVIEW.toLocaleLowerCase()) { 70 | handleReplaceState(tabList[4].key); 71 | setSelectedTab(tabList[4]); 72 | } else { 73 | handleReplaceState(tabList[3].key); 74 | setSelectedTab(tabList[3]); 75 | } 76 | } 77 | } 78 | // eslint-disable-next-line 79 | }, [viewMode, userInfo]); 80 | 81 | const handleReplaceState = (tab = '') => { 82 | window.history.replaceState( 83 | null, 84 | null, 85 | window.location.pathname + '?tab=' + tab.toLocaleLowerCase() 86 | ); 87 | }; 88 | 89 | const handleClickTab = (tab = {}) => { 90 | handleReplaceState(tab.key); 91 | setSelectedTab(tab); 92 | }; 93 | 94 | if (isEmpty(userInfo) || isEmpty(currentProject)) return null; 95 | 96 | if (isViewAsPO && !isPO) { 97 | // not permission to view this project 98 | setTimeout(() => { 99 | props.history.push(routes.PROJECTS); 100 | }, 0); 101 | 102 | return null; 103 | } 104 | 105 | return ( 106 |
107 |
108 |
109 |
110 | {tabList.map((tab) => { 111 | if (isViewAsPO && tab.isPO) { 112 | return ( 113 | handleClickTab(tab)} 119 | > 120 | {tab.name} 121 | 122 | ); 123 | } else if (!isViewAsPO && !tab.isPO) { 124 | return ( 125 | handleClickTab(tab)} 131 | > 132 | {tab.name} 133 | 134 | ); 135 | } else { 136 | return null; 137 | } 138 | })} 139 |
140 |
141 |
142 |
143 |
144 | {selectedTab.key === STUDY_TABS.DATA && isViewAsPO && ( 145 | 146 | )} 147 | {(selectedTab.key === STUDY_TABS.TASK || 148 | selectedTab.key === STUDY_TABS.ANNOTATE || 149 | selectedTab.key === STUDY_TABS.REVIEW) && ( 150 | 151 | )} 152 | {selectedTab.key === STUDY_TABS.SETTING && isViewAsPO && ( 153 | 154 | )} 155 |
156 |
157 | 158 | {currentProject?.document_link && ( 159 |
160 |
170 | )} 171 |
172 | ); 173 | }; 174 | 175 | export default connect( 176 | (state) => ({ 177 | userInfo: state.system.profile, 178 | viewMode: state.system.viewMode, 179 | currentProject: state.project.currentProject, 180 | }), 181 | { actionGetProjectDetail, actionSetProjectDetail } 182 | )(withRouter(StudyList)); 183 | -------------------------------------------------------------------------------- /src/view/studyList/StudyList.scss: -------------------------------------------------------------------------------- 1 | @import '../../variables.scss'; 2 | 3 | .study-list-page { 4 | display: flex; 5 | flex-direction: column; 6 | position: relative; 7 | .page-header { 8 | display: flex; 9 | min-height: 60px; 10 | 11 | .tab-list { 12 | z-index: 10; 13 | .tab-item { 14 | padding: 0 8px; 15 | cursor: pointer; 16 | margin-right: 5px; 17 | position: relative; 18 | font-size: 16px; 19 | &:hover { 20 | color: $app-primary-color; 21 | } 22 | &.active-item { 23 | color: $app-primary-color; 24 | &::after { 25 | content: ' '; 26 | width: 100%; 27 | height: 2px; 28 | background: $app-primary-color; 29 | position: absolute; 30 | bottom: -10px; 31 | left: 0; 32 | } 33 | } 34 | } 35 | } 36 | } 37 | .page-content { 38 | display: flex; 39 | flex: 1; 40 | overflow-y: auto; 41 | max-height: calc(100vh - 124px); 42 | .tab-content { 43 | display: flex; 44 | width: 100%; 45 | flex-direction: column; 46 | 47 | // css for header action 48 | .header-action { 49 | display: flex; 50 | position: absolute; 51 | top: 8px; 52 | z-index: 0; 53 | width: 100%; 54 | 55 | .col-item { 56 | text-align: right; 57 | } 58 | 59 | .search-box { 60 | height: 35px; 61 | margin-left: auto; 62 | border-radius: $border-box-radius; 63 | background: transparent; 64 | max-width: 250px; 65 | border-color: $app-box-border-color; 66 | &.ant-input-affix-wrapper-focused, 67 | &:hover { 68 | border-color: $app-primary-color; 69 | } 70 | .ant-input-prefix { 71 | color: $defautl-font-color; 72 | } 73 | .ant-input { 74 | background: transparent; 75 | color: $defautl-font-color; 76 | } 77 | } 78 | } 79 | 80 | // css for table content 81 | .table-wrapper { 82 | .header-table { 83 | display: flex; 84 | justify-content: space-between; 85 | padding: 8px 0 12px; 86 | height: 48px; 87 | align-items: flex-end; 88 | .top-btn-group { 89 | display: flex; 90 | align-items: flex-end; 91 | padding: 0 20px; 92 | .selected-info { 93 | margin-right: 20px; 94 | font-size: 12px; 95 | .label-txt { 96 | margin-right: 6px; 97 | } 98 | .selected-item { 99 | &.active { 100 | color: $app-primary-color; 101 | } 102 | } 103 | } 104 | 105 | .btn { 106 | margin-right: 12px; 107 | min-width: 75px; 108 | padding-left: 0; 109 | padding-right: 0; 110 | font-size: 12px; 111 | height: 28px; 112 | &:disabled { 113 | color: rgba(209, 209, 215, 0.6); 114 | background: transparent; 115 | border-color: rgba(209, 209, 215, 0.25); 116 | } 117 | } 118 | .btn-assign { 119 | text-align: center; 120 | .ant-btn { 121 | padding-left: 0; 122 | padding-right: 0; 123 | min-width: 75px; 124 | height: 28px; 125 | font-size: 12px; 126 | &:disabled { 127 | color: rgba(209, 209, 215, 0.6); 128 | border-color: rgba(209, 209, 215, 0.25); 129 | background: transparent; 130 | } 131 | } 132 | } 133 | } 134 | } 135 | .table-content { 136 | .study-status { 137 | color: $defautl-font-color; 138 | &.assigned { 139 | color: #ffa000; 140 | } 141 | &.completed { 142 | color: $app-primary-color; 143 | } 144 | } 145 | } 146 | } 147 | 148 | .right-panel { 149 | overflow: hidden; 150 | padding: 8px 12px; 151 | 152 | .box-content { 153 | margin-bottom: 25px; 154 | .box-title { 155 | font-size: 16px; 156 | margin-bottom: 8px; 157 | } 158 | .box-list { 159 | border: 1px solid $app-box-border-color; 160 | box-sizing: border-box; 161 | border-radius: $border-box-radius; 162 | padding: 12px 4px; 163 | max-height: 300px; 164 | overflow: hidden; 165 | overflow-y: auto; 166 | 167 | .box-item { 168 | display: flex; 169 | font-size: 14px; 170 | margin-bottom: 6px; 171 | padding: 0 12px; 172 | cursor: pointer; 173 | height: 26px; 174 | line-height: 26px; 175 | border-radius: $border-box-radius; 176 | 177 | &:hover, 178 | &.is-active { 179 | background-color: rgba(23, 185, 120, 0.2); 180 | } 181 | 182 | &:last-child { 183 | margin-bottom: 0; 184 | } 185 | .ic-item { 186 | margin-right: 5px; 187 | } 188 | .lb-name { 189 | white-space: nowrap; 190 | overflow: hidden; 191 | text-overflow: ellipsis; 192 | padding-right: 18px; 193 | } 194 | .total { 195 | margin-left: auto; 196 | } 197 | } 198 | } 199 | } 200 | } 201 | } 202 | } 203 | 204 | .fixed-widgets { 205 | position: fixed; 206 | bottom: 25px; 207 | right: 25px; 208 | .btn-open-guide { 209 | width: 42px; 210 | height: 42px; 211 | } 212 | } 213 | } 214 | 215 | .export-label-modal { 216 | .title { 217 | font-size: 16px; 218 | margin-bottom: 12px; 219 | } 220 | .exported-version { 221 | margin-bottom: 32px; 222 | .exported-table { 223 | border: 1px solid #d9d9d9; 224 | } 225 | .pagination-content { 226 | margin-top: 10px; 227 | text-align: right; 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/App.scss: -------------------------------------------------------------------------------- 1 | @import 'variables.scss'; 2 | 3 | .app-container { 4 | height: 100vh; 5 | .content-container { 6 | height: calc(100vh - #{$header-height}); 7 | overflow: auto; 8 | background: $app-bg-color; 9 | padding: 8px; 10 | color: $defautl-font-color; 11 | } 12 | } 13 | 14 | .common-style-page { 15 | background: $app-bg-page-content; 16 | border-radius: $border-box-radius; 17 | min-height: 100%; 18 | .top-content { 19 | .breadcrumb-content { 20 | padding: 8px 8px 0 8px; 21 | } 22 | .page-header { 23 | color: $defautl-font-color; 24 | padding: 8px; 25 | .title { 26 | font-size: 24px; 27 | } 28 | } 29 | } 30 | } 31 | .dark-table { 32 | .ant-table { 33 | font-size: 13px; 34 | .ant-checkbox:not(.ant-checkbox-checked) { 35 | .ant-checkbox-inner { 36 | background: transparent; 37 | } 38 | } 39 | 40 | .ant-table-content, 41 | .ant-table-container { 42 | background: $app-bg-page-content; 43 | color: $defautl-font-color; 44 | .ant-table-thead { 45 | background: $app-bg-page-content; 46 | tr { 47 | border: none; 48 | th { 49 | border: none; 50 | border-radius: 0; 51 | background: $app-bg-page-content; 52 | color: $defautl-font-color; 53 | } 54 | 55 | th.ant-table-column-has-sorters:hover { 56 | background: $app-bg-page-content; 57 | } 58 | } 59 | .ant-table-cell-scrollbar { 60 | box-shadow: none; 61 | } 62 | } 63 | .ant-table-tbody { 64 | tr { 65 | td { 66 | background: rgba(76, 78, 87, 0.4); 67 | border-bottom: 5px solid $app-bg-box; 68 | .ant-empty { 69 | color: $defautl-font-color; 70 | } 71 | } 72 | &.ant-table-row-selected { 73 | background-color: rgba(23, 185, 120, 0.2); 74 | td { 75 | // color: $app-primary-color; 76 | } 77 | } 78 | &:last-child { 79 | td { 80 | border-bottom: none; 81 | } 82 | } 83 | &:hover { 84 | td { 85 | background: rgba(76, 78, 87, 0.8); 86 | } 87 | } 88 | } 89 | } 90 | } 91 | } 92 | } 93 | 94 | @mixin modal-buttons { 95 | .ant-btn { 96 | min-width: 110px; 97 | min-height: 42px; 98 | border-radius: 4px; 99 | border: 1px solid $app-primary-color; 100 | box-sizing: border-box; 101 | color: $app-primary-color; 102 | box-shadow: 6px 16px 20px rgba(0, 0, 0, 0.06); 103 | } 104 | .ant-btn-primary { 105 | color: #fff; 106 | box-shadow: 0px 4px 10px rgba(16, 156, 241, 0.24); 107 | margin-left: 20px; 108 | } 109 | } 110 | 111 | .common-modal { 112 | .ant-modal-content { 113 | border-radius: 10px; 114 | .ant-modal-header { 115 | border-bottom: none; 116 | border-radius: 10px 10px 0 0; 117 | background: #dddddd; 118 | .ant-modal-title { 119 | font-size: 18px; 120 | font-weight: 500; 121 | color: #192a3e; 122 | } 123 | } 124 | .ant-modal-body { 125 | .ant-modal-confirm-btns { 126 | @include modal-buttons; 127 | } 128 | } 129 | .ant-modal-footer { 130 | padding: 13px 24px 24px 24px; 131 | border-top: none; 132 | border-bottom-left-radius: $border-box-radius; 133 | border-bottom-right-radius: $border-box-radius; 134 | @include modal-buttons; 135 | } 136 | } 137 | 138 | // common style for form item 139 | .ant-form { 140 | .ant-form-item { 141 | display: flex; 142 | flex-direction: column; 143 | .ant-form-item-label { 144 | .ant-form-item-required-comment { 145 | &::before { 146 | content: none; 147 | } 148 | &::after { 149 | display: inline-block; 150 | margin-left: 4px; 151 | color: #ff4d4f; 152 | font-size: 11px; 153 | line-height: 1; 154 | content: '*'; 155 | } 156 | } 157 | } 158 | } 159 | } 160 | } 161 | 162 | .fixed-upload-modal { 163 | .import-data-modal { 164 | .ant-modal-content { 165 | .ant-modal-close { 166 | height: 54px; 167 | line-height: 54px; 168 | width: unset; 169 | &:hover { 170 | color: rgba(0, 0, 0, 0.45); 171 | } 172 | .ant-modal-close-x { 173 | width: unset; 174 | height: 54px; 175 | line-height: 54px; 176 | cursor: default; 177 | .btn-header { 178 | padding-right: 16px; 179 | .btn-ic { 180 | margin-right: 10px; 181 | cursor: pointer; 182 | &:last-child { 183 | margin-right: 0; 184 | } 185 | &.btn-action:hover { 186 | color: $app-primary-color; 187 | } 188 | &.btn-close:hover { 189 | color: rgba(0, 0, 0, 0.75); 190 | } 191 | } 192 | } 193 | } 194 | } 195 | .ant-modal-body { 196 | padding: 0; 197 | 198 | .header-modal { 199 | height: 54px; 200 | display: flex; 201 | align-items: center; 202 | border-radius: 10px 10px 0 0; 203 | background: #dddddd; 204 | padding: 0 68px 0 24px; 205 | color: #192a3e; 206 | .header-title { 207 | font-size: 18px; 208 | font-weight: 500; 209 | } 210 | } 211 | } 212 | } 213 | } 214 | &.minimize-popup { 215 | position: static !important; 216 | .import-data-modal { 217 | position: fixed; 218 | z-index: 2; 219 | bottom: 20px; 220 | right: 20px; 221 | top: unset; 222 | width: 325px !important; 223 | padding: 0; 224 | 225 | .ant-modal-content { 226 | background: transparent; 227 | .ant-modal-close-x { 228 | color: $defautl-font-color; 229 | } 230 | .header-modal { 231 | background: $app-bg-box; 232 | color: $defautl-font-color; 233 | } 234 | .upload-container { 235 | display: none; 236 | } 237 | } 238 | } 239 | } 240 | } 241 | 242 | .tooltip-workflow-help { 243 | max-width: 400px; 244 | .tooltip-content { 245 | font-size: 12px; 246 | } 247 | } -------------------------------------------------------------------------------- /src/view/project/Project.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | import { connect } from 'react-redux'; 4 | import { Row, Col, Card, Tooltip, Button, Spin } from 'antd'; 5 | import { PlusCircleOutlined } from '@ant-design/icons'; 6 | import InfiniteScroll from 'react-infinite-scroller'; 7 | import { useIntl } from 'react-intl'; 8 | import { 9 | routes, 10 | STUDY_TABS, 11 | USER_ROLES, 12 | STUDY_STATUS, 13 | TASK_STATUS, 14 | } from '../../utils/constants/config'; 15 | import { isEmpty } from '../../utils/helpers'; 16 | import { getProjects } from './ProjectAction'; 17 | import { hasRolePO } from '../system/systemAction'; 18 | import CircularProgress from '../../components/circularProgress'; 19 | import CreateProjectModal from './CreateProjectModal'; 20 | import './Project.scss'; 21 | 22 | let params = { _offset: 0, _limit: 25 }; 23 | const Project = (props) => { 24 | const { history, userInfo, viewMode } = props; 25 | const { formatMessage: t } = useIntl(); 26 | const [isOpenModal, setOpenModal] = useState(false); 27 | const [projectData, setProjectData] = useState({}); 28 | const [isFetchingData, setFetchingData] = useState(false); 29 | const [hasMore, setHasMore] = useState(false); 30 | 31 | const isViewAsPO = viewMode === USER_ROLES.PROJECT_OWNER; 32 | 33 | useEffect(() => { 34 | if (!isEmpty(userInfo) && viewMode) { 35 | params.role = isViewAsPO 36 | ? [USER_ROLES.PROJECT_OWNER] 37 | : [USER_ROLES.ANNOTATOR, USER_ROLES.REVIEWER]; 38 | 39 | params._offset = 0; 40 | handleGetProjects(params); 41 | } 42 | 43 | return () => { 44 | params = { _offset: 0, _limit: 25 }; 45 | }; 46 | // eslint-disable-next-line 47 | }, [userInfo, viewMode]); 48 | 49 | const handleGetProjects = async (newParams = {}, isLoadmore) => { 50 | try { 51 | setFetchingData(true); 52 | const { data = {} } = await getProjects({ 53 | ...newParams, 54 | _offset: newParams._offset * newParams._limit, 55 | }); 56 | 57 | if (isLoadmore) { 58 | const currentData = projectData?.data || []; 59 | const newData = [...currentData, ...(data.data || [])]; 60 | setHasMore(newData.length < data.count); 61 | setProjectData({ ...data, data: newData }); 62 | } else { 63 | setHasMore((data.data || []).length < data.count); 64 | setProjectData({ ...data }); 65 | } 66 | 67 | setFetchingData(false); 68 | } catch (error) { 69 | console.log(error); 70 | setFetchingData(false); 71 | } 72 | }; 73 | 74 | const handleClickProject = (projectId) => { 75 | history.push(routes.STUDY_LIST + '/' + projectId); 76 | }; 77 | 78 | const createProjectSuccess = (project) => { 79 | setOpenModal(false); 80 | if (project?.data?.id) { 81 | props.history.push( 82 | routes.STUDY_LIST + 83 | '/' + 84 | project?.data?.id + 85 | '?tab=' + 86 | STUDY_TABS.SETTING.toLocaleLowerCase() 87 | ); 88 | } 89 | }; 90 | 91 | const handleGetStats = (data = {}) => { 92 | let total = 0; 93 | let numOfCompleted = 0; 94 | let content = ''; 95 | 96 | try { 97 | Object.keys(data).forEach((key) => { 98 | total += data[key]; 99 | }); 100 | 101 | if (isViewAsPO) { 102 | numOfCompleted = data[STUDY_STATUS.COMPLETED] || 0; 103 | content = `${numOfCompleted}/${total} ${t({ 104 | id: 'IDS_STUDIES_LOWER_CASE', 105 | })}`; 106 | } else { 107 | numOfCompleted = data[TASK_STATUS.COMPLETED] || 0; 108 | content = `${numOfCompleted}/${total} ${t({ id: 'IDS_TASKS' })}`; 109 | } 110 | } catch (error) {} 111 | 112 | const percent = (total === 0 ? 0 : numOfCompleted / total) * 100; 113 | 114 | return { total, numOfCompleted, percent: Math.round(percent), content }; 115 | }; 116 | 117 | return ( 118 |
119 |
120 |
121 |
{t({ id: 'IDS_PROJECTS' })}
122 | {hasRolePO(userInfo) && isViewAsPO && ( 123 | 124 |
133 |
134 |
135 | { 139 | if (isFetchingData || !hasMore) return; 140 | params = { ...params, _offset: params._offset + 1 }; 141 | handleGetProjects(params, true); 142 | }} 143 | hasMore={hasMore} 144 | useWindow={false} 145 | > 146 | 147 | {(projectData?.data || []).map((it) => { 148 | const stats = handleGetStats(it.meta) || {}; 149 | return ( 150 | 151 | } 156 | onClick={() => handleClickProject(it.id)} 157 | > 158 | 161 | {it.name} 162 | 163 | } 164 | description={ 165 | 166 | {t({ id: 'IDS_COMPLETE' })} 167 | 168 | {stats.content || ''} 169 | 170 | 171 | } 172 | /> 173 | 174 | 175 | ); 176 | })} 177 | 178 | {isFetchingData && hasMore && ( 179 |
180 | 181 |
182 | )} 183 |
184 |
185 | {isOpenModal && ( 186 | setOpenModal(false)} 188 | onOk={createProjectSuccess} 189 | /> 190 | )} 191 |
192 | ); 193 | }; 194 | 195 | export default connect( 196 | (state) => ({ 197 | userInfo: state.system.profile, 198 | viewMode: state.system.viewMode, 199 | }), 200 | {} 201 | )(withRouter(Project)); 202 | -------------------------------------------------------------------------------- /src/view/system/systemAction.js: -------------------------------------------------------------------------------- 1 | import cookie from 'js-cookie'; 2 | import api from '../../utils/service/api'; 3 | import * as actionType from '../../utils/constants/actions'; 4 | import { 5 | CONFIG_SERVER, 6 | routes, 7 | REFRESH_TOKEN, 8 | TOKEN, 9 | BASE_ROUTER_PREFIX, 10 | VINLAB_LOCALE, 11 | USER_ROLES, 12 | FIRST_REFRESH_TOKEN, 13 | ROLES, 14 | VINLAB_VIEW_MODE, 15 | } from '../../utils/constants/config'; 16 | 17 | const { 18 | CLIENT_ID, 19 | LOGIN_CALLBACK_URI, 20 | OIDC_LOGOUT_URI, 21 | OIDC_USERINFO_ENDPOINT, 22 | OIDC_ACCESS_TOKEN_URI, 23 | RESPONSE_TYPE, 24 | STATE, 25 | OIDC_AUTHORIZATION_URI, 26 | BASE_URL, 27 | SCOPE, 28 | AUDIENCE, 29 | TOKEN_PERMISSION, 30 | } = CONFIG_SERVER; 31 | 32 | const ENDPOINT_KEYCLOAK = '/auth/realms/vinlab/protocol/openid-connect'; 33 | 34 | export const actionChangeLanguage = (lang) => { 35 | cookie.set(VINLAB_LOCALE, lang); 36 | return { 37 | type: actionType.CHANGE_LANGUAGE, 38 | payload: lang, 39 | }; 40 | }; 41 | export const actionShowLoading = () => ({ type: actionType.SHOW_LOADING }); 42 | 43 | export const actionHideLoading = () => ({ type: actionType.HIDE_LOADING }); 44 | 45 | export const getAccountInfo = () => async (dispatch) => { 46 | try { 47 | dispatch(actionShowLoading()); 48 | const { data } = await api({ 49 | method: 'get', 50 | url: 51 | OIDC_USERINFO_ENDPOINT || `${BASE_URL + ENDPOINT_KEYCLOAK}/userinfo`, 52 | }); 53 | 54 | dispatch({ type: actionType.FETCHING_PROFILE, payload: data }); 55 | 56 | let isValidPage = false; 57 | const pathname = window.location.pathname || ''; 58 | Object.keys(routes).forEach((key) => { 59 | if (!isValidPage && pathname.indexOf(routes[key]) > -1) { 60 | isValidPage = true; 61 | } 62 | }); 63 | 64 | if (!isValidPage) { 65 | window.location.replace(BASE_ROUTER_PREFIX + routes.PROJECTS); 66 | } 67 | 68 | dispatch(actionHideLoading()); 69 | } catch (error) { 70 | console.log(error); 71 | dispatch(actionHideLoading()); 72 | actionLogout(); 73 | } 74 | }; 75 | 76 | export const actionGetToken = (code = '') => { 77 | let requestBody = new URLSearchParams(); 78 | requestBody.append('grant_type', 'authorization_code'); 79 | requestBody.append('client_id', CLIENT_ID); 80 | requestBody.append('code', code); 81 | requestBody.append('redirect_uri', LOGIN_CALLBACK_URI); 82 | 83 | return api({ 84 | method: 'post', 85 | url: OIDC_ACCESS_TOKEN_URI || `${BASE_URL + ENDPOINT_KEYCLOAK}/token`, 86 | data: requestBody, 87 | }); 88 | }; 89 | 90 | export const actionGetPermissionToken = (token, listPermission) => { 91 | let requestBody = new URLSearchParams(); 92 | requestBody.append( 93 | 'grant_type', 94 | 'urn:ietf:params:oauth:grant-type:uma-ticket' 95 | ); 96 | requestBody.append('audience', AUDIENCE); 97 | 98 | (listPermission || TOKEN_PERMISSION).forEach((it) => { 99 | requestBody.append('permission', it); 100 | }); 101 | 102 | return api({ 103 | method: 'post', 104 | url: OIDC_ACCESS_TOKEN_URI || `${BASE_URL + ENDPOINT_KEYCLOAK}/token`, 105 | data: requestBody, 106 | headers: { 107 | Authorization: `Bearer ${token}`, 108 | }, 109 | }); 110 | }; 111 | 112 | export const actionRefreshToken = (refreshToken = '') => { 113 | let requestBody = new URLSearchParams(); 114 | requestBody.append('grant_type', 'refresh_token'); 115 | requestBody.append('client_id', CLIENT_ID); 116 | requestBody.append('refresh_token', refreshToken); 117 | requestBody.append('redirect_uri', LOGIN_CALLBACK_URI); 118 | 119 | return api({ 120 | method: 'post', 121 | url: OIDC_ACCESS_TOKEN_URI || `${BASE_URL + ENDPOINT_KEYCLOAK}/token`, 122 | data: requestBody, 123 | }); 124 | }; 125 | 126 | export const requestLogin = () => { 127 | const pathAuth = 128 | OIDC_AUTHORIZATION_URI || `${BASE_URL + ENDPOINT_KEYCLOAK}/auth`; 129 | let loginUrl = 130 | pathAuth + 131 | '?client_id=' + 132 | CLIENT_ID + 133 | '&response_type=' + 134 | RESPONSE_TYPE + 135 | '&state=' + 136 | STATE + 137 | '&scope=' + 138 | SCOPE + 139 | '&redirect_uri=' + 140 | LOGIN_CALLBACK_URI; 141 | 142 | window.location.href = encodeURI(loginUrl); 143 | }; 144 | 145 | export const actionLogout = async () => { 146 | try { 147 | localStorage.removeItem(VINLAB_VIEW_MODE); 148 | if (cookie.get(REFRESH_TOKEN) || cookie.get(FIRST_REFRESH_TOKEN)) { 149 | let requestBody = new URLSearchParams(); 150 | requestBody.append('client_id', CLIENT_ID); 151 | requestBody.append('redirect_uri', LOGIN_CALLBACK_URI); 152 | requestBody.append( 153 | 'refresh_token', 154 | cookie.get(REFRESH_TOKEN) || cookie.get(FIRST_REFRESH_TOKEN) 155 | ); 156 | await api({ 157 | method: 'post', 158 | url: OIDC_LOGOUT_URI || `${BASE_URL + ENDPOINT_KEYCLOAK}/logout`, 159 | data: requestBody, 160 | }); 161 | } 162 | cookie.remove(TOKEN); 163 | cookie.remove(REFRESH_TOKEN); 164 | requestLogin(); 165 | } catch (error) { 166 | cookie.remove(TOKEN); 167 | cookie.remove(REFRESH_TOKEN); 168 | requestLogin(); 169 | } 170 | }; 171 | 172 | export const checkRole = (profile, role = '') => { 173 | if (!profile || !role) return false; 174 | const { realm_roles = [] } = profile; 175 | return realm_roles.indexOf(role) > -1; 176 | }; 177 | 178 | export const hasRolePO = (profile) => { 179 | return checkRole(profile, ROLES.PO) || checkRole(profile, ROLES.PO_PARTNER); 180 | }; 181 | 182 | export const actionChangeViewMode = (viewmode) => async (dispatch) => { 183 | try { 184 | localStorage.setItem(VINLAB_VIEW_MODE, viewmode); 185 | dispatch({ type: actionType.CHANGE_VIEW_MODE, payload: viewmode }); 186 | } catch (error) {} 187 | }; 188 | 189 | export const isProjectOwner = (projectInfo, userInfo) => { 190 | if (!projectInfo || !userInfo) return false; 191 | const { people = [] } = projectInfo; 192 | const listPO = people.filter( 193 | (it) => 194 | it.id === userInfo.sub && 195 | (it?.roles || []).indexOf(USER_ROLES.PROJECT_OWNER) > -1 196 | ); 197 | return (listPO || []).length > 0; 198 | }; 199 | 200 | export const actionGetUsers = (params = {}) => async (dispatch) => { 201 | try { 202 | dispatch({ type: actionType.FETCHING_USERS, payload: true }); 203 | const { data } = await api({ 204 | method: 'get', 205 | url: '/api/accounts/userinfo', 206 | params, 207 | }); 208 | dispatch({ type: actionType.FETCH_USERS, payload: data }); 209 | } catch (error) { 210 | dispatch({ type: actionType.FETCHING_USERS, payload: false }); 211 | } 212 | }; 213 | 214 | export const actionGetListPermission = (token = '') => { 215 | return api({ 216 | method: 'get', 217 | url: '/api/accounts/permissions', 218 | headers: { Authorization: `Bearer ${token}` }, 219 | }); 220 | }; 221 | 222 | export const actionShowUploadModal = (uploadInfo = {}) => { 223 | return { 224 | type: actionType.SHOW_UPLOAD_MODAL, 225 | payload: uploadInfo, 226 | }; 227 | }; 228 | -------------------------------------------------------------------------------- /src/view/studyList/data/ExportLabelModal.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { 3 | Form, 4 | Modal, 5 | message, 6 | Input, 7 | Button, 8 | Table, 9 | Spin, 10 | Pagination, 11 | } from 'antd'; 12 | import { connect } from 'react-redux'; 13 | import moment from 'moment'; 14 | import { useIntl } from 'react-intl'; 15 | import { 16 | actionExportLabel, 17 | actionGetExportedVersions, 18 | actionDownloadLabel, 19 | } from '../StudyListAction'; 20 | import { CONFIG_SERVER } from '../../../utils/constants/config'; 21 | 22 | let downloadLabelUrl = 23 | CONFIG_SERVER.BASE_URL + '/api/stats/label_exports/download/'; 24 | let params = { _offset: 0, _limit: 10 }; 25 | let intervalExport = null; 26 | const EXPORT_STATUS = { 27 | PENDING: 'PENDING', 28 | DONE: 'DONE', 29 | }; 30 | 31 | const ExportLabelModal = (props) => { 32 | const { formatMessage: t } = useIntl(); 33 | const [processing, setProcessing] = useState(false); 34 | const { visible = true, onCancel, exportedVersion, projectId } = props; 35 | const [form] = Form.useForm(); 36 | 37 | useEffect(() => { 38 | return () => { 39 | params = { _offset: 0, _limit: 10 }; 40 | clearInterval(intervalExport); 41 | }; 42 | }, []); 43 | 44 | useEffect(() => { 45 | if (projectId) { 46 | params = { 47 | ...params, 48 | _search: `project_id:${projectId}`, 49 | }; 50 | handleGetExportedVersion(params); 51 | } 52 | // eslint-disable-next-line 53 | }, [projectId]); 54 | 55 | useEffect(() => { 56 | if (projectId && exportedVersion?.data) { 57 | const hasPending = (exportedVersion?.data || []).find( 58 | (it) => it.status === EXPORT_STATUS.PENDING 59 | ); 60 | if (hasPending && !intervalExport) { 61 | intervalExport = setInterval(() => { 62 | handleGetExportedVersion(params); 63 | }, 1000); 64 | } else if (intervalExport) { 65 | clearInterval(intervalExport); 66 | } 67 | } 68 | // eslint-disable-next-line 69 | }, [projectId, exportedVersion]); 70 | 71 | const handleGetExportedVersion = (newParams = {}) => { 72 | props.actionGetExportedVersions({ 73 | ...newParams, 74 | _offset: newParams._offset * newParams._limit, 75 | }); 76 | }; 77 | 78 | const handleOk = (event) => { 79 | if (processing) return; 80 | event.stopPropagation(); 81 | form 82 | .validateFields() 83 | .then(async (values) => { 84 | try { 85 | const dataDTO = { 86 | tag: values.tag.trim(), 87 | project_id: projectId, 88 | }; 89 | setProcessing(true); 90 | await actionExportLabel(dataDTO); 91 | params._offset = 0; 92 | handleGetExportedVersion(params); 93 | form.resetFields(['tag']); 94 | message.success('Creating file!'); 95 | setProcessing(false); 96 | } catch (error) { 97 | const { data = {} } = error || {}; 98 | message.error(data.message || 'System error'); 99 | setProcessing(false); 100 | } 101 | }) 102 | .catch((error) => {}); 103 | }; 104 | 105 | const columns = [ 106 | { 107 | title: t({ id: 'IDS_VERSION_TAG' }), 108 | dataIndex: 'tag', 109 | key: 'tag', 110 | }, 111 | { 112 | title: t({ id: 'IDS_DATE' }), 113 | dataIndex: 'created', 114 | key: 'created', 115 | render: (txt) => ( 116 | {txt ? moment(txt).format('YYYY-MM-DD HH:mm') : ''} 117 | ), 118 | }, 119 | { 120 | title: t({ id: 'IDS_STATUS' }), 121 | dataIndex: 'status', 122 | key: 'status', 123 | render: (txt) => { 124 | const status = (txt || '').toLocaleLowerCase(); 125 | return {status}; 126 | }, 127 | }, 128 | { 129 | title: t({ id: 'IDS_ACTION' }), 130 | width: 150, 131 | align: 'center', 132 | dataIndex: 'action', 133 | key: 'action', 134 | render: (_, record) => ( 135 | 144 | ), 145 | }, 146 | ]; 147 | 148 | const onChangePagination = (page, size) => { 149 | params = { ...params, _offset: page - 1, _limit: size }; 150 | handleGetExportedVersion(params); 151 | }; 152 | 153 | return ( 154 | 163 | {t({ id: 'IDS_EXPORT' })} 164 | , 165 | ]} 166 | > 167 | 168 |
169 |
170 |
{t({ id: 'IDS_EXPORTED_VERSIONS' })}
171 | record.id} 175 | className="exported-table" 176 | dataSource={exportedVersion?.data || []} 177 | columns={columns} 178 | pagination={false} 179 | /> 180 | 191 | 192 |
193 |
{t({ id: 'IDS_EXPORT_NEW_VERSION' })}
194 |
195 | 210 | 211 | 212 | 213 |
214 | 215 | 216 | 217 | ); 218 | }; 219 | 220 | export default connect( 221 | (state) => ({ 222 | exportedVersion: state.study.exportedVersion, 223 | isFetching: state.study.isFetching, 224 | }), 225 | { actionGetExportedVersions } 226 | )(ExportLabelModal); 227 | -------------------------------------------------------------------------------- /src/assets/icons/vinlab_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/labels/EditLabelModal.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | Form, 4 | Modal, 5 | message, 6 | Input, 7 | Spin, 8 | Popover, 9 | Button, 10 | Select, 11 | } from 'antd'; 12 | import { CirclePicker } from 'react-color'; 13 | import { useIntl } from 'react-intl'; 14 | import { 15 | DEFAULT_COLOR_PICKER, 16 | LABEL_SCOPE, 17 | LABEL_TYPE, 18 | ANNOTATION_TYPE, 19 | } from '../../utils/constants/config'; 20 | import { isEmpty } from '../../utils/helpers'; 21 | import { actionUpdateLabel, actionDeleteLabel } from './LabelsAction'; 22 | 23 | const { Option } = Select; 24 | 25 | const EditLabelModal = (props) => { 26 | const { visible = true, onCancel, onOk, item = {} } = props; 27 | const [processing, setProcessing] = useState(false); 28 | const [color, setColor] = useState(item?.color || '#4caf50'); 29 | const [form] = Form.useForm(); 30 | const intl = useIntl(); 31 | const { formatMessage: t } = intl; 32 | 33 | const handleOk = (event) => { 34 | event.stopPropagation(); 35 | if (processing || isEmpty(item)) return; 36 | form 37 | .validateFields() 38 | .then(async (values) => { 39 | try { 40 | setProcessing(true); 41 | const dataDTO = { 42 | name: values.name?.trim(), 43 | short_name: values.short_name?.trim() || '', 44 | description: values.description?.trim() || '', 45 | color: color, 46 | }; 47 | await actionUpdateLabel(item.id, dataDTO); 48 | setProcessing(false); 49 | if (onOk) onOk(); 50 | } catch (error) { 51 | message.error('Error'); 52 | setProcessing(false); 53 | } 54 | }) 55 | .catch((error) => {}); 56 | }; 57 | 58 | const handleCancel = () => { 59 | if (processing) return; 60 | if (onCancel) onCancel(); 61 | }; 62 | 63 | const handleDeleteLabel = async () => { 64 | try { 65 | setProcessing(true); 66 | await actionDeleteLabel(item.id); 67 | setProcessing(false); 68 | if (onOk) onOk(); 69 | } catch (error) { 70 | message.error('Error'); 71 | setProcessing(false); 72 | } 73 | }; 74 | 75 | const layout = { 76 | labelCol: { span: 6 }, 77 | wrapperCol: { span: 18 }, 78 | }; 79 | 80 | return ( 81 | 97 | {t({ id: 'IDS_DELETE' })} 98 | , 99 | , 102 | ]} 103 | > 104 | 105 |
106 |
122 | 123 | 130 | 131 | 132 | 139 | 140 | 145 | 152 | 153 | 166 | 167 | 168 | 169 | 170 | 171 | 176 | 177 | 178 | 182 | 188 | 189 | 190 | 191 | 192 | 193 | setColor(newColor.hex)} 199 | /> 200 | } 201 | trigger="click" 202 | > 203 | 209 | 210 | 211 | 212 |
213 |
214 |
215 | ); 216 | }; 217 | 218 | export default EditLabelModal; 219 | -------------------------------------------------------------------------------- /src/components/labels/NewLabelModal.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { 4 | Form, 5 | Modal, 6 | message, 7 | Input, 8 | Spin, 9 | Select, 10 | Popover, 11 | Button, 12 | } from 'antd'; 13 | import { CirclePicker } from 'react-color'; 14 | import { useIntl } from 'react-intl'; 15 | import { 16 | DEFAULT_COLOR_PICKER, 17 | LABEL_SCOPE, 18 | LABEL_TYPE, 19 | ANNOTATION_TYPE, 20 | } from '../../utils/constants/config'; 21 | import { actionCreateLabel } from './LabelsAction'; 22 | 23 | const { Option } = Select; 24 | 25 | const NewLabelModal = (props) => { 26 | const { 27 | visible = true, 28 | onCancel, 29 | onOk, 30 | projectId, 31 | labels, 32 | selectedGroup, 33 | } = props; 34 | const [forceUpdate, setForceUpdate] = useState(0); 35 | const [processing, setProcessing] = useState(false); 36 | const [color, setColor] = useState('#4caf50'); 37 | const [parentList, setParentList] = useState([]); 38 | const [form] = Form.useForm(); 39 | const { getFieldValue, resetFields } = form; 40 | const intl = useIntl(); 41 | const { formatMessage: t } = intl; 42 | 43 | const onChangeLabelType = (value) => { 44 | resetFields(['parent_label_id', 'scope', 'annotation_type']); 45 | setParentList(labels?.data?.[value] || []); 46 | }; 47 | 48 | const onChangeScope = (value) => { 49 | resetFields(['parent_label_id']); 50 | if (getFieldValue('type') === LABEL_TYPE[0].value) { 51 | const newParents = (labels?.data?.[LABEL_TYPE[0].value] || []).filter( 52 | (it) => it.scope === value 53 | ); 54 | setParentList(newParents || []); 55 | } 56 | }; 57 | 58 | const onChangeAnnotationType = (value) => { 59 | resetFields(['parent_label_id']); 60 | if (getFieldValue('type') === LABEL_TYPE[1].value) { 61 | const newParents = (labels?.data?.[getFieldValue('type')] || []).filter( 62 | (it) => it.annotation_type === value 63 | ); 64 | setParentList(newParents || []); 65 | } 66 | setForceUpdate(1 - forceUpdate); 67 | }; 68 | 69 | const handleOk = (event) => { 70 | if (processing) return; 71 | event.stopPropagation(); 72 | form 73 | .validateFields() 74 | .then(async (values) => { 75 | try { 76 | setProcessing(true); 77 | const dataDTO = { 78 | ...values, 79 | name: values.name?.trim(), 80 | short_name: values.short_name?.trim() || '', 81 | description: values.description?.trim() || '', 82 | color: color, 83 | label_group_id: selectedGroup?.id, 84 | }; 85 | console.log(values); 86 | await actionCreateLabel(dataDTO, projectId); 87 | setProcessing(false); 88 | if (onOk) onOk(); 89 | } catch (error) { 90 | message.error('Error'); 91 | setProcessing(false); 92 | } 93 | }) 94 | .catch((error) => {}); 95 | }; 96 | 97 | const handleCancel = () => { 98 | if (processing) return; 99 | if (onCancel) onCancel(); 100 | }; 101 | 102 | const layout = { 103 | labelCol: { span: 6 }, 104 | wrapperCol: { span: 18 }, 105 | }; 106 | 107 | return ( 108 | 119 | 120 |
121 |
122 | 127 | 134 | 135 | 140 | 151 | 152 | 157 | 171 | 172 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 196 | 197 | 198 | 203 | 209 | 210 | 214 | 221 | 222 | 223 | setColor(newColor.hex)} 229 | /> 230 | } 231 | trigger="click" 232 | > 233 | 239 | 240 | 241 | 242 |
243 |
244 |
245 | ); 246 | }; 247 | 248 | export default connect( 249 | (state) => ({ labels: state.label.labels }), 250 | {} 251 | )(NewLabelModal); 252 | -------------------------------------------------------------------------------- /src/view/studyList/data/AssignLabelerModal.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { 3 | Form, 4 | Modal, 5 | message, 6 | Button, 7 | Spin, 8 | Select, 9 | Radio, 10 | InputNumber, 11 | Upload, 12 | Row, 13 | Col, 14 | } from 'antd'; 15 | import { UploadOutlined } from '@ant-design/icons'; 16 | import { useIntl } from 'react-intl'; 17 | import { actionAssignTaskCondition } from '../StudyListAction'; 18 | import { USER_ROLES, WORKFLOW_PROJECT } from '../../../utils/constants/config'; 19 | 20 | const { Option } = Select; 21 | 22 | const STUDY_POOL = { 23 | SELECTED_ITEM: 'SELECTED', 24 | SEARCH_CONDITION: 'SEARCH', 25 | UPLOAD_FILE: 'FILE', 26 | }; 27 | 28 | let studyInstanceUIDs = []; 29 | 30 | const AssignLabelerModal = (props) => { 31 | const { formatMessage: t } = useIntl(); 32 | const [form] = Form.useForm(); 33 | 34 | const { 35 | visible = true, 36 | onCancel, 37 | searchCondition = {}, 38 | selectedItem = [], 39 | currentProject = {}, 40 | } = props; 41 | const [isProcessing, setProcessing] = useState(false); 42 | const [studyPool, setStudyPool] = useState(''); 43 | const [reviewerList, setReviewList] = useState([]); 44 | const [annotatorList, setAnnotatorList] = useState([]); 45 | const [fileList, setFileList] = useState([]); 46 | 47 | useEffect(() => { 48 | return () => { 49 | studyInstanceUIDs = []; 50 | }; 51 | }, []); 52 | 53 | useEffect(() => { 54 | if (currentProject) { 55 | const annotators = (currentProject?.people || []) 56 | .filter((usr) => (usr.roles || []).indexOf(USER_ROLES.ANNOTATOR) >= 0) 57 | .map((it) => ({ ...it, label: it?.username, value: it?.id })); 58 | const reviewers = (currentProject?.people || []) 59 | .filter((usr) => (usr.roles || []).indexOf(USER_ROLES.REVIEWER) >= 0) 60 | .map((it) => ({ ...it, label: it?.username, value: it?.id })); 61 | 62 | setAnnotatorList(annotators || []); 63 | setReviewList(reviewers || []); 64 | } 65 | // eslint-disable-next-line 66 | }, [currentProject]); 67 | 68 | const handleOk = (event) => { 69 | event.stopPropagation(); 70 | if (isProcessing || !currentProject.id) return; 71 | 72 | form 73 | .validateFields() 74 | .then(async (values) => { 75 | if (currentProject?.workflow === WORKFLOW_PROJECT.SINGLE) { 76 | if ((values.reviewer || []).length === 0) { 77 | message.error('You need at least 1 reviewer for Single workflow'); 78 | return; 79 | } 80 | } else if (currentProject?.workflow === WORKFLOW_PROJECT.TRIANGLE) { 81 | if ( 82 | (values.reviewer || []).length < 1 || 83 | (values.annotator || []).length < 2 84 | ) { 85 | message.error( 86 | 'You need at least 2 annotators, 1 reviewer for Triangle workflow' 87 | ); 88 | return; 89 | } 90 | } 91 | 92 | setProcessing(true); 93 | try { 94 | const dataPost = { 95 | project_id: currentProject.id, 96 | assignee_ids: { 97 | REVIEW: values.reviewer, 98 | ANNOTATE: values.annotator, 99 | }, 100 | strategy: values.strategy, 101 | source_type: values.source_type, 102 | }; 103 | 104 | if ( 105 | studyPool === STUDY_POOL.SELECTED_ITEM && 106 | selectedItem.length > 0 107 | ) { 108 | dataPost.study_ids = selectedItem; 109 | } else if ( 110 | studyPool === STUDY_POOL.UPLOAD_FILE && 111 | studyInstanceUIDs.length > 0 112 | ) { 113 | dataPost.study_instance_uids = studyInstanceUIDs; 114 | } else if (studyPool === STUDY_POOL.SEARCH_CONDITION) { 115 | dataPost.search_query = { 116 | size: values.size, 117 | query: searchCondition._search || undefined, 118 | status: searchCondition.status || undefined, 119 | }; 120 | } 121 | await actionAssignTaskCondition(dataPost); 122 | setProcessing(false); 123 | onCancel(true); 124 | } catch (error) { 125 | message.error('System error!'); 126 | setProcessing(false); 127 | } 128 | }) 129 | .catch((error) => {}); 130 | }; 131 | 132 | const handleChangeStudyPool = (value) => { 133 | setStudyPool(value); 134 | }; 135 | 136 | const validFile = (file) => { 137 | const fileExt = (file.name || '').split('.').pop(); 138 | const isTxtFile = 139 | file.type === 'text/plain' || 140 | (fileExt || '').toLocaleLowerCase() === 'txt'; 141 | const isLt2M = file.size > 0 && file.size / 1024 / 1024 < 2; 142 | return isTxtFile && isLt2M; 143 | }; 144 | 145 | const normFile = (e) => { 146 | if (e && e.file && validFile(e.file) && e.file.status !== 'removed') { 147 | return e.file; 148 | } 149 | 150 | if (e.file.status === 'removed') { 151 | return undefined; 152 | } 153 | return (fileList.length > 0 && fileList[0]) || undefined; 154 | }; 155 | 156 | const onRemoveFile = () => { 157 | studyInstanceUIDs = []; 158 | setFileList([]); 159 | }; 160 | 161 | const onBeforeUpload = (file) => { 162 | try { 163 | if (validFile(file)) { 164 | studyInstanceUIDs = []; 165 | const reader = new FileReader(); 166 | reader.readAsText(file); 167 | reader.onload = () => { 168 | const fileResult = `${reader.result || ''}`.split('\n'); 169 | studyInstanceUIDs = (fileResult || []).filter((it) => it); 170 | if (studyInstanceUIDs.length > 0) { 171 | setFileList([file]); 172 | } else { 173 | message.error('File error!'); 174 | } 175 | setProcessing(false); 176 | }; 177 | reader.onprogress = (ev) => { 178 | setProcessing(true); 179 | }; 180 | reader.onerror = () => { 181 | message.error('File error!'); 182 | setProcessing(false); 183 | }; 184 | setFileList([file]); 185 | } else { 186 | message.error( 187 | 'You can only upload TXT file and must smaller than 2MB!' 188 | ); 189 | } 190 | } catch (error) { 191 | console.log(error); 192 | } 193 | 194 | return false; 195 | }; 196 | 197 | return ( 198 | 212 | {t({ id: 'IDS_ASSIGN' })} 213 | , 214 | ]} 215 | > 216 | 217 |
218 |
224 | 229 | 241 | 242 | 243 | {studyPool === STUDY_POOL.UPLOAD_FILE && ( 244 | 251 | 259 | 260 | 261 | 262 | )} 263 | {studyPool === STUDY_POOL.SEARCH_CONDITION && ( 264 | 270 | 271 | 272 | )} 273 | 274 |
275 | 280 | 287 | 288 | 289 | 290 | 291 | 298 | 299 | 300 | 301 | 302 | 308 | 309 | All 310 | Equally 311 | 312 | 313 | 314 | 315 | 316 | 317 | ); 318 | }; 319 | 320 | export default AssignLabelerModal; 321 | --------------------------------------------------------------------------------