diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt index e0b5f3a9bc..87db17154b 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/ProjectStatsController.kt @@ -6,17 +6,21 @@ package io.tolgee.api.v2.controllers import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.dtos.request.task.TaskFilters import io.tolgee.hateoas.project.stats.LanguageStatsModelAssembler import io.tolgee.hateoas.project.stats.ProjectStatsModel import io.tolgee.model.enums.Scope +import io.tolgee.model.enums.TaskState import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.AllowApiAccess import io.tolgee.security.authorization.RequiresProjectPermissions import io.tolgee.security.authorization.UseDefaultPermissions +import io.tolgee.service.TaskService import io.tolgee.service.language.LanguageService import io.tolgee.service.project.LanguageStatsService import io.tolgee.service.project.ProjectService import io.tolgee.service.project.ProjectStatsService +import org.springframework.data.domain.Pageable import org.springframework.hateoas.MediaTypes import org.springframework.web.bind.annotation.CrossOrigin import org.springframework.web.bind.annotation.GetMapping @@ -36,6 +40,7 @@ class ProjectStatsController( private val languageStatsService: LanguageStatsService, private val languageStatsModelAssembler: LanguageStatsModelAssembler, private val languageService: LanguageService, + private val taskService: TaskService, ) { @Operation(summary = "Get project stats") @GetMapping("", produces = [MediaTypes.HAL_JSON_VALUE]) @@ -62,6 +67,7 @@ class ProjectStatsController( projectId = projectStats.id, languageCount = languageStats.size, keyCount = projectStats.keyCount, + taskCount = projectStats.taskCount, baseWordsCount = totals.baseWordsCount, translatedPercentage = totals.translatedPercent, reviewedPercentage = totals.reviewedPercent, diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/stats/ProjectStatsModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/stats/ProjectStatsModel.kt index 836372fe4e..c1958fdabb 100644 --- a/backend/api/src/main/kotlin/io/tolgee/hateoas/project/stats/ProjectStatsModel.kt +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/project/stats/ProjectStatsModel.kt @@ -5,6 +5,7 @@ open class ProjectStatsModel( val projectId: Long, val languageCount: Int, val keyCount: Long, + val taskCount: Long, val baseWordsCount: Long, val translatedPercentage: Double, val reviewedPercentage: Double, diff --git a/backend/data/src/main/kotlin/io/tolgee/model/views/projectStats/ProjectStatsView.kt b/backend/data/src/main/kotlin/io/tolgee/model/views/projectStats/ProjectStatsView.kt index 2be32f71a5..09a3bbdf06 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/views/projectStats/ProjectStatsView.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/views/projectStats/ProjectStatsView.kt @@ -5,4 +5,5 @@ data class ProjectStatsView( val keyCount: Long, val memberCount: Long, val tagCount: Long, + val taskCount: Long ) diff --git a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt index b470c2ba4a..20e410acdd 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/queryBuilders/ProjectStatsProvider.kt @@ -7,10 +7,13 @@ import io.tolgee.model.Project import io.tolgee.model.Project_ import io.tolgee.model.UserAccount import io.tolgee.model.UserAccount_ +import io.tolgee.model.enums.TaskState import io.tolgee.model.key.Key import io.tolgee.model.key.Key_ import io.tolgee.model.key.Tag import io.tolgee.model.key.Tag_ +import io.tolgee.model.task.Task +import io.tolgee.model.task.Task_ import io.tolgee.model.views.projectStats.ProjectStatsView import io.tolgee.util.KotlinCriteriaBuilder import jakarta.persistence.EntityManager @@ -36,6 +39,7 @@ open class ProjectStatsProvider( getKeyCountSelection(), getMemberCountSelection(), getTagSelection(), + getTaskCountSelection() ) query.multiselect(selection) @@ -66,7 +70,6 @@ open class ProjectStatsProvider( val permissionJoin = subProject.join(Project_.permissions, JoinType.LEFT) val organizationJoin = subProject.join(Project_.organizationOwner, JoinType.LEFT) val rolesJoin = organizationJoin.join(Organization_.memberRoles, JoinType.LEFT) - sub.where( project equal subProject and ( @@ -76,4 +79,12 @@ open class ProjectStatsProvider( ) return sub.select(cb.countDistinct(subUserAccount.get(UserAccount_.id))) } + + private fun getTaskCountSelection(): Selection { + val sub = query.subquery(Long::class.java) + val task = sub.from(Task::class.java) + sub.where(task.get(Task_.project) equal project) + sub.where(task.get(Task_.state).`in`(listOf(TaskState.NEW, TaskState.IN_PROGRESS))) + return sub.select(cb.coalesce(cb.countDistinct(task.get(Tag_.id)), 0)) + } } diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index fcc9317be3..ef86d3341e 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -1085,6 +1085,10 @@ export interface components { | "slack_workspace_already_connected" | "slack_connection_error" | "email_verification_code_not_valid" + | "cannot_subscribe_to_free_plan" + | "plan_auto_assignment_only_for_free_plans" + | "plan_auto_assignment_only_for_private_plans" + | "plan_auto_assignment_organization_ids_not_in_for_organization_ids" | "task_not_found" | "task_not_finished" | "task_not_open"; @@ -1160,21 +1164,11 @@ export interface components { | "SERVER_ADMIN"; /** @description The user's permission type. This field is null if uses granular permissions */ type?: "NONE" | "VIEW" | "TRANSLATE" | "REVIEW" | "EDIT" | "MANAGE"; - /** - * @description List of languages user can translate to. If null, all languages editing is permitted. - * @example 200001,200004 - */ - translateLanguageIds?: number[]; /** * @description List of languages user can view. If null, all languages view is permitted. * @example 200001,200004 */ viewLanguageIds?: number[]; - /** - * @description List of languages user can change state to. If null, changing state of all language values is permitted. - * @example 200001,200004 - */ - stateChangeLanguageIds?: number[]; /** * @description Granted scopes to the user. When user has type permissions, this field contains permission scopes of the type. * @example KEYS_EDIT,TRANSLATIONS_VIEW @@ -1209,6 +1203,16 @@ export interface components { | "tasks.view" | "tasks.edit" )[]; + /** + * @description List of languages user can translate to. If null, all languages editing is permitted. + * @example 200001,200004 + */ + translateLanguageIds?: number[]; + /** + * @description List of languages user can change state to. If null, changing state of all language values is permitted. + * @example 200001,200004 + */ + stateChangeLanguageIds?: number[]; /** * @deprecated * @description Deprecated (use translateLanguageIds). @@ -2071,12 +2075,12 @@ export interface components { createNewKeys: boolean; }; ImportSettingsModel: { + /** @description If false, only updates keys, skipping the creation of new keys */ + createNewKeys: boolean; /** @description If true, placeholders from other formats will be converted to ICU when possible */ convertPlaceholdersToIcu: boolean; /** @description If true, key descriptions will be overridden by the import */ overrideKeyDescriptions: boolean; - /** @description If false, only updates keys, skipping the creation of new keys */ - createNewKeys: boolean; }; TranslationCommentModel: { /** @@ -2233,17 +2237,17 @@ export interface components { }; RevealedPatModel: { token: string; - description: string; /** Format: int64 */ id: number; /** Format: int64 */ - expiresAt?: number; - /** Format: int64 */ - lastUsedAt?: number; - /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; + /** Format: int64 */ + expiresAt?: number; + /** Format: int64 */ + lastUsedAt?: number; + description: string; }; SetOrganizationRoleDto: { roleType: "MEMBER" | "OWNER"; @@ -2379,19 +2383,19 @@ export interface components { RevealedApiKeyModel: { /** @description Resulting user's api key */ key: string; - description: string; /** Format: int64 */ id: number; - /** Format: int64 */ - projectId: number; + userFullName?: string; + projectName: string; + username?: string; /** Format: int64 */ expiresAt?: number; + scopes: string[]; + /** Format: int64 */ + projectId: number; /** Format: int64 */ lastUsedAt?: number; - username?: string; - scopes: string[]; - projectName: string; - userFullName?: string; + description: string; }; SuperTokenRequest: { /** @description Has to be provided when TOTP enabled */ @@ -2466,7 +2470,6 @@ export interface components { languageId: number; assignees: number[]; keys: number[]; - state?: "NEW" | "IN_PROGRESS" | "DONE" | "CLOSED"; }; CalculateScopeRequest: { /** Format: int64 */ @@ -2476,11 +2479,11 @@ export interface components { }; KeysScopeView: { /** Format: int64 */ - wordCount: number; + characterCount: number; /** Format: int64 */ keyCount: number; /** Format: int64 */ - characterCount: number; + wordCount: number; }; GetKeysRequestDto: { keys: components["schemas"]["KeyDefinitionDto"][]; @@ -2818,6 +2821,10 @@ export interface components { | "slack_workspace_already_connected" | "slack_connection_error" | "email_verification_code_not_valid" + | "cannot_subscribe_to_free_plan" + | "plan_auto_assignment_only_for_free_plans" + | "plan_auto_assignment_only_for_private_plans" + | "plan_auto_assignment_organization_ids_not_in_for_organization_ids" | "task_not_found" | "task_not_finished" | "task_not_open"; @@ -3185,6 +3192,7 @@ export interface components { isPlural?: boolean; /** @description List of services to use. If null, then all enabled services are used. */ services?: ("GOOGLE" | "AWS" | "DEEPL" | "AZURE" | "BAIDU" | "TOLGEE")[]; + plural?: boolean; }; PagedModelTranslationMemoryItemModel: { _embedded?: { @@ -3537,8 +3545,6 @@ export interface components { | "SLACK_INTEGRATION" )[]; quickStart?: components["schemas"]["QuickStartModel"]; - /** @example This is a beautiful organization full of beautiful and clever people */ - description?: string; /** @example Beautiful organization */ name: string; /** Format: int64 */ @@ -3553,6 +3559,8 @@ export interface components { avatar?: components["schemas"]["Avatar"]; /** @example btforg */ slug: string; + /** @example This is a beautiful organization full of beautiful and clever people */ + description?: string; }; PublicBillingConfigurationDTO: { enabled: boolean; @@ -3613,9 +3621,9 @@ export interface components { defaultFileStructureTemplate: string; }; DocItem: { - description?: string; name: string; displayName?: string; + description?: string; }; PagedModelProjectModel: { _embedded?: { @@ -3710,23 +3718,23 @@ export interface components { formalitySupported: boolean; }; KeySearchResultView: { - description?: string; name: string; /** Format: int64 */ id: number; baseTranslation?: string; - translation?: string; namespace?: string; + description?: string; + translation?: string; }; KeySearchSearchResultModel: { view?: components["schemas"]["KeySearchResultView"]; - description?: string; name: string; /** Format: int64 */ id: number; baseTranslation?: string; - translation?: string; namespace?: string; + description?: string; + translation?: string; }; PagedModelKeySearchSearchResultModel: { _embedded?: { @@ -4205,6 +4213,8 @@ export interface components { /** Format: int64 */ keyCount: number; /** Format: int64 */ + taskCount: number; + /** Format: int64 */ baseWordsCount: number; /** Format: double */ translatedPercentage: number; @@ -4290,17 +4300,17 @@ export interface components { }; PatWithUserModel: { user: components["schemas"]["SimpleUserAccountModel"]; - description: string; /** Format: int64 */ id: number; /** Format: int64 */ - expiresAt?: number; - /** Format: int64 */ - lastUsedAt?: number; - /** Format: int64 */ createdAt: number; /** Format: int64 */ updatedAt: number; + /** Format: int64 */ + expiresAt?: number; + /** Format: int64 */ + lastUsedAt?: number; + description: string; }; PagedModelOrganizationModel: { _embedded?: { @@ -4417,19 +4427,19 @@ export interface components { * @description Languages for which user has translate permission. */ permittedLanguageIds?: number[]; - description: string; /** Format: int64 */ id: number; - /** Format: int64 */ - projectId: number; + userFullName?: string; + projectName: string; + username?: string; /** Format: int64 */ expiresAt?: number; + scopes: string[]; + /** Format: int64 */ + projectId: number; /** Format: int64 */ lastUsedAt?: number; - username?: string; - scopes: string[]; - projectName: string; - userFullName?: string; + description: string; }; PagedModelUserAccountModel: { _embedded?: { diff --git a/webapp/src/views/projects/dashboard/ActivityList.tsx b/webapp/src/views/projects/dashboard/ActivityList.tsx index 6fd59f2c2e..d4e13a96d0 100644 --- a/webapp/src/views/projects/dashboard/ActivityList.tsx +++ b/webapp/src/views/projects/dashboard/ActivityList.tsx @@ -93,7 +93,7 @@ export const ActivityList: React.FC = ({ activityLoadable }) => { return ( - + = ({ languageStats, wordCount }) => { + const theme = useTheme(); const languages = useProjectLanguages(); const { satisfiesLanguageAccess, satisfiesPermission } = useProjectPermissions(); @@ -95,6 +106,7 @@ export const LanguageStats: FC = ({ languageStats, wordCount }) => { const baseLanguage = languages.find((l) => l.base === true)!.tag; const allLangs = languages.map((l) => l.tag); const canViewLanguages = satisfiesPermission('translations.view'); + const canEditLanguages = satisfiesPermission('languages.edit'); const redirectToLanguage = (lang?: string) => { const langs = !lang @@ -111,6 +123,26 @@ export const LanguageStats: FC = ({ languageStats, wordCount }) => { return ( + + + {t('dashboard_languages_title')} + + + {canEditLanguages && ( + + + + )} + {languageStats.map((item, i) => { const language = languages.find((l) => l.id === item.languageId)!; const canViewLanguage = satisfiesLanguageAccess( diff --git a/webapp/src/views/projects/dashboard/ProjectTotals.tsx b/webapp/src/views/projects/dashboard/ProjectTotals.tsx index 9666153db6..07963e6827 100644 --- a/webapp/src/views/projects/dashboard/ProjectTotals.tsx +++ b/webapp/src/views/projects/dashboard/ProjectTotals.tsx @@ -146,9 +146,9 @@ export const ProjectTotals: React.FC<{ setAnchorEl(null); }; - const redirectToLanguages = () => { + const redirectToTasks = () => { history.push( - LINKS.PROJECT_LANGUAGES.build({ [PARAMS.PROJECT_ID]: project.id }) + LINKS.PROJECT_TASKS.build({ [PARAMS.PROJECT_ID]: project.id }) ); }; @@ -182,9 +182,9 @@ export const ProjectTotals: React.FC<{ const { satisfiesPermission } = useProjectPermissions(); const canViewMembers = satisfiesPermission('members.view'); - const canEditLanguages = satisfiesPermission('languages.edit'); const canViewKeys = satisfiesPermission('keys.view'); const canEditMembers = satisfiesPermission('members.edit'); + const canViewTasks = satisfiesPermission('tasks.view'); const tagsPresent = Boolean(stats.tagCount); const tagsClickable = tagsPresent && canViewKeys; @@ -201,25 +201,20 @@ export const ProjectTotals: React.FC<{ - {Number(stats.languageCount).toLocaleString(locale)} + {Number(stats.taskCount).toLocaleString(locale)} - {t('project_dashboard_language_count', 'Languages', { - count: stats.languageCount, + {t('project_dashboard_task_count', { + count: stats.taskCount, })} - {canEditLanguages && ( - - - - )}