From 09d46f87247da9c8bb61fdfff22b1441f43bf83f Mon Sep 17 00:00:00 2001 From: shweta2101 Date: Mon, 27 Jul 2026 17:27:59 +0530 Subject: [PATCH 01/11] Area unit toggle, task editor & permission updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce area display unit support and refine task editor UX and permission checks. - Add composable useAreaDisplayUnit to persist/display km² or mi², plus formatArea helper and AREA_UNIT_OPTIONS. - Wire area unit toggle into project-wizard AreaOfInterestStep and TaskSetupPanel, replacing ad-hoc formatting with formatArea; display AOI area in ReviewStep. - Update RichTextEditor and SettingsStep to use v-model for instructions synchronization. - Overhaul task editor sidebar: add tabs (Completion / Instructions), review remap flow, lock time remaining display, action disabling/validation for review feedback, status labels, and styling improvements. - Improve project detail/edit pages: fetch TDEI roles (tdeiUserClient), introduce useWorkspaceProjectPermissions (in useProjectRole), gate edit/management actions and UI (contributors, activation, edit button) based on workspace/project permissions. - Misc UI/text updates: change "More mapping needed" → "More mapping required", adjust sort labels, remove duplicate "Lead" role option in AddContributorDialog, and several CSS/layout tweaks. - Add unit tests for area display unit and project service behavior and update related types/util functions. These changes centralize area unit logic, improve mapping/review flows, and tighten role-based UI/operation gating. --- components/project-wizard/RichTextEditor.vue | 16 +- .../steps/AreaOfInterestStep.vue | 90 ++- .../project-wizard/steps/ReviewStep.vue | 5 + .../project-wizard/steps/SettingsStep.vue | 9 +- .../AddContributorDialog.vue | 1 - .../workspace-project-details/ProjectMap.vue | 2 +- .../TaskSetupPanel.vue | 107 ++- .../workspace-project-details/TasksTab.vue | 6 +- composables/useAreaDisplayUnit.ts | 78 ++ composables/useProjectRole.ts | 26 + .../[id]/projects/[projectId]/edit.vue | 63 +- .../[id]/projects/[projectId]/index.vue | 74 +- .../[projectId]/tasks/[taskId]/editor.vue | 715 +++++++++++++----- pages/workspace/[id]/projects/create.vue | 30 +- pages/workspace/[id]/projects/index.vue | 14 +- services/project-wizard-review.ts | 19 +- services/projects.ts | 38 +- services/tdei.ts | 9 + .../composables/useAreaDisplayUnit.test.ts | 12 + test/unit/services/projects.test.ts | 100 +++ types/projects.ts | 12 +- util/task-status.ts | 5 +- 22 files changed, 1159 insertions(+), 272 deletions(-) create mode 100644 composables/useAreaDisplayUnit.ts create mode 100644 test/unit/composables/useAreaDisplayUnit.test.ts create mode 100644 test/unit/services/projects.test.ts diff --git a/components/project-wizard/RichTextEditor.vue b/components/project-wizard/RichTextEditor.vue index 8e3b9eb..4b49635 100644 --- a/components/project-wizard/RichTextEditor.vue +++ b/components/project-wizard/RichTextEditor.vue @@ -47,14 +47,7 @@ type RichTextToolId | 'table' | 'quote'; -interface Props { - modelValue: string; -} - -const props = defineProps(); -const emit = defineEmits<{ - 'update:modelValue': [value: string]; -}>(); +const model = defineModel({ default: '' }); const tools: Array<{ icon: string; id: RichTextToolId; label: string }> = [ { id: 'bold', icon: 'format_bold', label: 'Bold' }, @@ -68,7 +61,7 @@ const tools: Array<{ icon: string; id: RichTextToolId; label: string }> = [ ]; const editor = useEditor({ - content: props.modelValue || '

', + content: model.value || '

', extensions: [ StarterKit, Link.configure({ @@ -90,13 +83,12 @@ const editor = useEditor({ }, }, onUpdate({ editor: currentEditor }) { - const nextValue = currentEditor.isEmpty ? '' : currentEditor.getHTML(); - emit('update:modelValue', nextValue); + model.value = currentEditor.isEmpty ? '' : currentEditor.getHTML(); }, }); watch( - () => props.modelValue, + model, (value) => { const currentEditor = editor.value; diff --git a/components/project-wizard/steps/AreaOfInterestStep.vue b/components/project-wizard/steps/AreaOfInterestStep.vue index b3e7bfc..3dbdcb2 100644 --- a/components/project-wizard/steps/AreaOfInterestStep.vue +++ b/components/project-wizard/steps/AreaOfInterestStep.vue @@ -52,6 +52,27 @@ +
+ Area: {{ formattedArea }} +
+ +
+
+ + + {{ formattedTaskArea }} +

- Each task will be generated for {{ formattedTaskArea }} km2 + Each task will be generated for {{ formattedTaskArea }}

@@ -78,7 +97,7 @@ size="18" no-margin /> - {{ generatedSummary ? 'Generate Again' : 'Generate Tasks' }} + {{ taskGenerationActionLabel }} @@ -93,7 +112,7 @@ size="18" no-margin /> - Reset + Reset @@ -109,7 +128,7 @@
Tasks created

This project will be created with {{ generatedSummary.totalTasks }} tasks.

-

The size of each task is approximately {{ formattedTaskArea }} km2

+

The size of each task is approximately {{ formattedTaskArea }}

@@ -232,16 +251,20 @@ const emit = defineEmits<{ * replace this with `useId()` (available in Vue 3.5+) to generate a unique ID per instance. */ const rangeInputId = 'project-task-setup-range'; +const { areaUnit, areaUnitOptions, selectAreaUnit } = useAreaDisplayUnit(); /** Formatted strings for the min/max labels and current value display. */ const formattedMinimumTaskArea = computed(() => - formatSquareKilometers(props.minimumTaskAreaSquareKilometers), + formatArea(props.minimumTaskAreaSquareKilometers, areaUnit.value), ); const formattedMaximumTaskArea = computed(() => - formatSquareKilometers(props.maximumTaskAreaSquareKilometers), + formatArea(props.maximumTaskAreaSquareKilometers, areaUnit.value), ); const formattedTaskArea = computed(() => - formatSquareKilometers(props.taskAreaSquareKilometers), + formatArea(props.taskAreaSquareKilometers, areaUnit.value), +); +const taskGenerationActionLabel = computed(() => + props.generatedSummary ? 'Generate Again' : 'Generate Tasks', ); /** @@ -260,14 +283,6 @@ const rangeProgressPercent = computed(() => { return ((props.taskAreaSquareKilometers - props.minimumTaskAreaSquareKilometers) / spread) * 100; }); -/** Format a raw km² number for display (e.g. 1.5 → "1.5", 1000 → "1,000"). */ -function formatSquareKilometers(value: number): string { - return new Intl.NumberFormat('en-US', { - maximumFractionDigits: 2, - minimumFractionDigits: 0, - }).format(value); -} - /** * The range input uses a controlled pattern (`:value` binding + `@input` handler) rather * than `v-model` because the parent needs to normalise the value before accepting it. @@ -382,6 +397,48 @@ function onTaskAreaInput(event: Event) { font-weight: 500; } +.project-task-setup-range-value { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.project-task-setup-unit-toggle { + display: inline-flex; + padding: 0.2rem; + background: #f1f3f9; + border: 1px solid rgba($text-navy, 0.12); + border-radius: 0.55rem; +} + +.project-task-setup-unit-option { + min-width: 3.25rem; + padding: 0.35rem 0.55rem; + color: #667091; + font-size: 0.85rem; + font-weight: 700; + line-height: 1; + background: transparent; + border: 0; + border-radius: 0.4rem; +} + +.project-task-setup-unit-option:hover, +.project-task-setup-unit-option:focus-visible { + color: $text-navy; +} + +.project-task-setup-unit-option:focus-visible { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba($primary, 0.16); +} + +.project-task-setup-unit-option-active { + color: $text-navy; + background: #ffffff; + box-shadow: 0 0.1rem 0.3rem rgba($text-navy, 0.12); +} + .project-task-setup-range-header strong { color: #56648d; font-size: 1rem; @@ -467,6 +524,17 @@ function onTaskAreaInput(event: Event) { font-size: 1rem; font-weight: 500; border-radius: 0.6rem; + white-space: nowrap; +} + +.project-task-setup-action :deep(.material-icons) { + flex: 0 0 auto; + margin-top: 0; + line-height: 1; +} + +.project-task-setup-action > span { + line-height: 1.2; } .project-task-setup-action-generate { @@ -566,5 +634,10 @@ function onTaskAreaInput(event: Event) { align-items: flex-start; flex-direction: column; } + + .project-task-setup-range-value { + width: 100%; + justify-content: space-between; + } } diff --git a/components/workspace-project-details/TasksTab.vue b/components/workspace-project-details/TasksTab.vue index e1040c4..65418bb 100644 --- a/components/workspace-project-details/TasksTab.vue +++ b/components/workspace-project-details/TasksTab.vue @@ -235,15 +235,15 @@ const statusOptions: SelectOption[] = [ { label: 'All', value: 'all' }, { label: 'Ready for mapping', value: 'ready_for_mapping' }, { label: 'Ready for validation', value: 'ready_for_validation' }, - { label: 'More mapping needed', value: 'needs_more_mapping' }, + { label: 'More mapping required', value: 'needs_more_mapping' }, { label: 'Completed', value: 'completed' }, ]; const sortOptions: SelectOption[] = [ { label: 'Latest', value: 'latest' }, { label: 'Oldest', value: 'oldest' }, - { label: 'Task ID A-Z', value: 'task_asc' }, - { label: 'Task ID Z-A', value: 'task_desc' }, + { label: 'Task # Low to High', value: 'task_asc' }, + { label: 'Task # High to Low', value: 'task_desc' }, ]; /** diff --git a/composables/useAreaDisplayUnit.ts b/composables/useAreaDisplayUnit.ts new file mode 100644 index 0000000..1622143 --- /dev/null +++ b/composables/useAreaDisplayUnit.ts @@ -0,0 +1,78 @@ +export type AreaDisplayUnit = 'square_kilometers' | 'square_miles'; + +const AREA_UNIT_PREFERENCE_KEY = 'task-area-display-unit'; +const SQUARE_MILES_PER_SQUARE_KILOMETER = 0.3861021585; + +export const AREA_UNIT_OPTIONS: Array<{ label: string; value: AreaDisplayUnit }> = [ + { label: 'km²', value: 'square_kilometers' }, + { label: 'mi²', value: 'square_miles' }, +]; + +export function formatArea( + squareKilometers: number, + displayUnit: AreaDisplayUnit, +): string { + const convertedValue = displayUnit === 'square_miles' + ? squareKilometers * SQUARE_MILES_PER_SQUARE_KILOMETER + : squareKilometers; + const formattedValue = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2, + minimumFractionDigits: 0, + }).format(convertedValue); + + return `${formattedValue} ${displayUnit === 'square_miles' ? 'mi²' : 'km²'}`; +} + +export function useAreaDisplayUnit() { + const areaUnit = ref('square_kilometers'); + + onMounted(() => { + let savedAreaUnit: string | null = null; + + try { + savedAreaUnit = window.localStorage.getItem(AREA_UNIT_PREFERENCE_KEY); + } + catch { + // Storage can be unavailable in privacy-restricted browser contexts. + } + + if (isAreaDisplayUnit(savedAreaUnit)) { + areaUnit.value = savedAreaUnit; + return; + } + + if (isUnitedStatesLocale(window.navigator.language)) { + areaUnit.value = 'square_miles'; + } + }); + + function selectAreaUnit(nextUnit: AreaDisplayUnit) { + areaUnit.value = nextUnit; + + try { + window.localStorage.setItem(AREA_UNIT_PREFERENCE_KEY, nextUnit); + } + catch { + // The in-memory preference still applies for the current page. + } + } + + return { + areaUnit, + areaUnitOptions: AREA_UNIT_OPTIONS, + selectAreaUnit, + }; +} + +function isAreaDisplayUnit(value: string | null): value is AreaDisplayUnit { + return value === 'square_kilometers' || value === 'square_miles'; +} + +function isUnitedStatesLocale(locale: string): boolean { + try { + return new Intl.Locale(locale).maximize().region === 'US'; + } + catch { + return false; + } +} diff --git a/composables/useProjectRole.ts b/composables/useProjectRole.ts index 2d72585..3a80f98 100644 --- a/composables/useProjectRole.ts +++ b/composables/useProjectRole.ts @@ -1,7 +1,33 @@ import { workspaceProjectsClient } from '~/services/index'; +import type { MaybeRefOrGetter } from 'vue'; import type { WorkspaceProjectContributorRole } from '~/types/projects'; import type { WorkspaceRole } from '~/types/workspaces'; +/** + * Resolves workspace-level project permissions shared by project navigation and management routes. + * A TDEI POC is scoped to the owning project group, so callers must supply the roles for that + * project group rather than the user's roles from an unrelated group. + */ +export function useWorkspaceProjectPermissions( + workspaceRole: MaybeRefOrGetter, + myTdeiRoles: MaybeRefOrGetter, +) { + const isWorkspaceLead = computed(() => toValue(workspaceRole) === 'lead'); + const isPoc = computed(() => toValue(myTdeiRoles).includes('poc')); + const hasWorkspaceLeadPrivileges = computed(() => + isWorkspaceLead.value || isPoc.value, + ); + + return { + canCreateProject: hasWorkspaceLeadPrivileges, + canDeleteProject: hasWorkspaceLeadPrivileges, + canEditProjectMetadata: hasWorkspaceLeadPrivileges, + canManageProjectLifecycle: hasWorkspaceLeadPrivileges, + isPoc, + isWorkspaceLead, + }; +} + /** * Resolves the current user's effective role for a project. * diff --git a/pages/workspace/[id]/projects/[projectId]/edit.vue b/pages/workspace/[id]/projects/[projectId]/edit.vue index 39c4ff5..1ab8c6f 100644 --- a/pages/workspace/[id]/projects/[projectId]/edit.vue +++ b/pages/workspace/[id]/projects/[projectId]/edit.vue @@ -127,8 +127,7 @@ @@ -326,7 +325,7 @@

Lock Timeout

-

Project will be locked for specific hours.

+

Unlock tasks after the specified number of hours pass.

@@ -471,7 +470,7 @@ import { toast } from 'vue3-toastify'; import 'vue3-toastify/dist/index.css'; import { useProjectEditActions } from '~/composables/useProjectEditActions'; import { useProjectEditMembers } from '~/composables/useProjectEditMembers'; -import { workspaceProjectsClient, workspacesClient } from '~/services/index'; +import { tdeiUserClient, workspaceProjectsClient, workspacesClient } from '~/services/index'; import { resolveHttpErrorMessage } from '~/services/http'; import { validateProjectCustomImagery } from '~/services/project-custom-imagery'; @@ -516,7 +515,7 @@ const { const IMAGERY_VALIDATION_DEBOUNCE_MS = 300; const imagerySchemaUrl = import.meta.env.VITE_IMAGERY_SCHEMA; -const sections: ProjectEditSection[] = [ +const BASE_SECTIONS: ProjectEditSection[] = [ { id: 'details', label: 'Project details' }, { id: 'instructions', label: 'Instructions' }, { id: 'imagery', label: 'Imagery' }, @@ -527,7 +526,40 @@ const sections: ProjectEditSection[] = [ const hourOptions = Array.from({ length: 24 }, (_, index) => index + 1); const workspace = await workspacesClient.getWorkspace(workspaceId); +const myTdeiRoles = await tdeiUserClient.getMyRolesForProjectGroupById( + workspace.tdeiProjectGroupId, +).catch(() => []); +const { canEditProjectMetadata } = useWorkspaceProjectPermissions( + () => workspace.role, + myTdeiRoles, +); + +if (!canEditProjectMetadata.value) { + throw createError({ + statusCode: 403, + statusMessage: 'Only workspace leads or project group POCs can edit this project.', + }); +} + const project = ref(await loadProjectDetail()); +const currentUserIdForRole = workspaceProjectsClient.auth.subject || null; +const { + canManageContributors, + promise: rolePromise, +} = useProjectRole( + workspaceId, + projectId, + currentUserIdForRole, + workspace.role, +); +await rolePromise; + +const sections = computed(() => + BASE_SECTIONS.filter(section => + section.id !== 'team' || canManageContributors.value, + ), +); + const initialCustomImageryJson = formatCustomImagery(project.value.customImagery); const initialProjectContributors = await loadProjectContributors(); const saving = ref(false); @@ -568,25 +600,6 @@ const form = reactive({ reviewRequired: project.value.reviewRequired, }); -const currentUserIdForRole = workspaceProjectsClient.auth.subject || null; -const { - isProjectLead, - promise: rolePromise, -} = useProjectRole( - workspaceId, - projectId, - currentUserIdForRole, - workspace.role, -); -await rolePromise; - -if (!isProjectLead.value) { - throw createError({ - statusCode: 403, - statusMessage: 'Only project leads can edit this project.', - }); -} - useHead({ title: computed(() => `Edit ${project.value.name} | Projects`), }); @@ -594,7 +607,7 @@ useHead({ const activeSection = computed(() => { const section = route.query.section; - if (typeof section === 'string' && sections.some(item => item.id === section)) { + if (typeof section === 'string' && sections.value.some(item => item.id === section)) { return section as ProjectEditSectionId; } diff --git a/pages/workspace/[id]/projects/[projectId]/index.vue b/pages/workspace/[id]/projects/[projectId]/index.vue index d23af61..009e140 100644 --- a/pages/workspace/[id]/projects/[projectId]/index.vue +++ b/pages/workspace/[id]/projects/[projectId]/index.vue @@ -251,7 +251,7 @@ import { PROJECT_WIZARD_TASK_AREA_MINIMUM, PROJECT_WIZARD_TASK_AREA_STEP, } from '~/services/project-wizard-tasks'; -import { workspaceProjectsClient, workspacesClient } from '~/services/index'; +import { tdeiUserClient, workspaceProjectsClient, workspacesClient } from '~/services/index'; import { resolveHttpErrorMessage } from '~/services/http'; import { resolveWorkspaceProjectTaskStatusLabel } from '~/util/task-status'; @@ -290,6 +290,16 @@ const BASE_TABS: ProjectDetailTabOption[] = [ ]; const workspace = await workspacesClient.getWorkspace(workspaceId); +const myTdeiRoles = await tdeiUserClient.getMyRolesForProjectGroupById( + workspace.tdeiProjectGroupId, +).catch(() => []); +const { + canEditProjectMetadata, + canManageProjectLifecycle, +} = useWorkspaceProjectPermissions( + () => workspace.role, + myTdeiRoles, +); const project = ref(await loadProjectDetail()); const projectAoi = ref(await loadProjectAoi()); const projectTasks = ref(await loadProjectTasks()); @@ -301,8 +311,6 @@ const projectGroupUsers = ref([]); const currentUserIdForRole = workspaceProjectsClient.auth.subject || null; const { effectiveRole, - isProjectLead, - isExplicitProjectLead, canValidate, canMap, canManageContributors, @@ -316,12 +324,12 @@ const { await rolePromise; /** - * The Contributors tab is only visible to project leads. + * Project roles can be managed by workspace leads or project leads. * All other tabs are always visible. */ const tabs = computed(() => [ ...BASE_TABS, - ...(isExplicitProjectLead.value ? [{ id: 'contributors' as WorkspaceProjectDetailTab, label: 'Contributors' }] : []), + ...(canManageContributors.value ? [{ id: 'contributors' as WorkspaceProjectDetailTab, label: 'Contributors' }] : []), ]); // The detail API does not expose separate rich-text fields for overview content yet, @@ -446,6 +454,11 @@ const selectedTaskLockedByCurrentUser = computed(() => Boolean(selectedTask.value?.lock?.user_id) && selectedTask.value?.lock?.user_id === currentUserId.value, ); +const selectedTaskWasLastMappedByCurrentUser = computed(() => + selectedTask.value?.status === 'ready_for_validation' + && Boolean(selectedTask.value.lastMapperId) + && selectedTask.value.lastMapperId === currentUserId.value, +); const showSelectedTaskBar = computed(() => !showTaskSetup.value && Boolean(selectedTask.value), @@ -475,6 +488,10 @@ const selectedTaskPrimaryActionLabel = computed(() => { return selectedTaskLockedByCurrentUser.value ? selectedTaskWorkActionLabel.value : 'Task Locked'; } + if (selectedTaskWasLastMappedByCurrentUser.value) { + return 'Cannot Validate Own Mapping'; + } + return selectedTaskWorkActionLabel.value; }); @@ -497,6 +514,7 @@ const showSelectedTaskActionButton = computed(() => { const selectedTaskActionDisabled = computed(() => !selectedTask.value || mutatingTaskNumber.value === selectedTask.value.taskNumber + || selectedTaskWasLastMappedByCurrentUser.value || (selectedTask.value.locked && !selectedTaskLockedByCurrentUser.value), ); const selectedTaskActionBusy = computed(() => @@ -506,9 +524,9 @@ const selectedTaskActionBusy = computed(() => ), ); const showActivateProjectButton = computed(() => - projectRequiresActivation.value && isProjectLead.value, + projectRequiresActivation.value && canManageProjectLifecycle.value, ); -const showProjectEditButton = computed(() => isProjectLead.value); +const showProjectEditButton = computed(() => canEditProjectMetadata.value); let projectGroupUserSearchDebounce: ReturnType | undefined; let projectGroupUserSearchRequestId = 0; @@ -606,6 +624,8 @@ async function handleSaveTasks() { return; } + // Keep the project summary in sync while the paginated task-list request reloads all tasks. + project.value.taskCount = result.taskCount; await refreshProjectTaskStateAfterSave(); openTaskSaveSuccessDialog(result); } @@ -627,6 +647,10 @@ function clearSelectedTask() { } async function openProjectEditPage() { + if (!canEditProjectMetadata.value) { + return; + } + await navigateTo({ path: `/workspace/${workspaceId}/projects/${projectId}/edit`, }); @@ -657,6 +681,13 @@ async function handleSelectedTaskAction() { return; } + if ( + taskToOpen.status === 'ready_for_validation' + && taskToOpen.lastMapperId === currentUserId.value + ) { + return; + } + if (taskToOpen.locked) { if (!selectedTaskLockedByCurrentUser.value) { return; @@ -675,6 +706,10 @@ async function handleSelectedTaskAction() { } async function handleActivateProject() { + if (!canManageProjectLifecycle.value) { + return; + } + try { isActivatingProject.value = true; @@ -771,6 +806,10 @@ async function loadProjectGroupUsers(searchText: string = '') { } function handleOpenAddContributorDialog() { + if (!canManageContributors.value) { + return; + } + if (projectGroupUsersLoaded.value || projectGroupUsersLoading.value) { return; } @@ -779,6 +818,10 @@ function handleOpenAddContributorDialog() { } function handleSearchAvailableUsers(value: string) { + if (!canManageContributors.value) { + return; + } + projectGroupUserSearchQuery.value = value; if (projectGroupUserSearchDebounce) { @@ -794,6 +837,12 @@ async function handleAddContributor(payload: { role: WorkspaceProjectContributor['role']; userId: string; }) { + // A project has one lead, established when the project is created. The add-contributor + // workflow may grant tasking roles but must never create another project lead. + if (!canManageContributors.value || payload.role === 'lead') { + return; + } + if (projectContributors.value.some(contributor => contributor.id === payload.userId)) { return; } @@ -813,6 +862,10 @@ async function handleAddContributor(payload: { } async function confirmRemoveContributor(contributor: WorkspaceProjectContributor) { + if (!canManageContributors.value) { + return; + } + const value = await create({ title: 'Remove Contributor', body: `Remove ${contributor.name} from this project?`, @@ -847,6 +900,10 @@ async function handleUpdateContributorRole(payload: { contributorId: string; role: WorkspaceProjectContributor['role']; }) { + if (!canManageContributors.value) { + return; + } + const existingContributor = projectContributors.value.find( contributor => contributor.id === payload.contributorId, ); @@ -908,8 +965,7 @@ onBeforeUnmount(() => { }); async function hydrateProjectDataFromApi() { - // Refresh the independent project, AOI, and task resources opportunistically so one - // failed request does not wipe out the rest of the page state. + // Refresh independent resources concurrently so one failure does not wipe out the rest. const [projectResult, aoiResult, tasksResult, contributorsResult] = await Promise.allSettled([ workspaceProjectsClient.getWorkspaceProjectDetail(workspaceId, projectId), workspaceProjectsClient.getWorkspaceProjectAoi(workspaceId, projectId), diff --git a/pages/workspace/[id]/projects/[projectId]/tasks/[taskId]/editor.vue b/pages/workspace/[id]/projects/[projectId]/tasks/[taskId]/editor.vue index e971ec3..f0e69df 100644 --- a/pages/workspace/[id]/projects/[projectId]/tasks/[taskId]/editor.vue +++ b/pages/workspace/[id]/projects/[projectId]/tasks/[taskId]/editor.vue @@ -35,30 +35,41 @@
-
-

- Rapid task editor -

- - -
+

{{ project.name }} - #{{ project.id }}

+
+ {{ task.label }} +
+ {{ taskStatusLabel }} + + + {{ lockTimeRemaining }} + +
+
+

-
-
- Task number - {{ task.label }} +
+ + +
+ +
+
+ + + +
+

Task status

+

{{ taskStatusHelpText }}

+
+ +
+

Ready to finish mapping?

+

+ Push your edits in Rapid before marking this task as done. You can skip the task to + release the lock without submitting it. +

+
+ +
+
+ Review this mapping + + +
+ +
+ + + + +