Merge upstream/main into landing-page
# Conflicts: # frontend/src/router/index.ts
This commit is contained in:
commit
0aa3952e34
|
|
@ -26,6 +26,7 @@ docs/resources/
|
||||||
pkg/static/templates_vfsdata.go
|
pkg/static/templates_vfsdata.go
|
||||||
files/
|
files/
|
||||||
!pkg/files/
|
!pkg/files/
|
||||||
|
!pkg/web/files/
|
||||||
vikunja-dump*
|
vikunja-dump*
|
||||||
vendor/
|
vendor/
|
||||||
os-packages/
|
os-packages/
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,13 @@ linters:
|
||||||
- revive
|
- revive
|
||||||
path: pkg/utils/*
|
path: pkg/utils/*
|
||||||
text: 'var-naming: avoid meaningless package names'
|
text: 'var-naming: avoid meaningless package names'
|
||||||
|
- linters:
|
||||||
|
- revive
|
||||||
|
path: pkg/routes/api/shared/*
|
||||||
|
text: 'var-naming: avoid meaningless package names'
|
||||||
|
- linters:
|
||||||
|
- contextcheck
|
||||||
|
path: pkg/routes/api/v2/backgrounds.go # the unsplash provider intentionally uses context.Background(); its interface is shared with v1 and can't take a context
|
||||||
- linters:
|
- linters:
|
||||||
- revive
|
- revive
|
||||||
text: 'var-naming: avoid package names that conflict with Go standard library package names'
|
text: 'var-naming: avoid package names that conflict with Go standard library package names'
|
||||||
|
|
|
||||||
|
|
@ -997,6 +997,37 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "audit",
|
||||||
|
"comment": "Audit logging writes structured JSONL records of authentication, authorization and data lifecycle events. Requires the licensed `audit_logs` feature — with `audit.enabled: true` but no active license, listeners are registered but nothing is written until a license with the feature becomes active.",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"key": "enabled",
|
||||||
|
"default_value": "false",
|
||||||
|
"comment": "Whether to enable audit logging."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "logfile",
|
||||||
|
"default_value": "",
|
||||||
|
"comment": "The file audit log entries are written to, one JSON object per line. If empty, defaults to `audit.log` in the configured log path."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "rotation",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"key": "maxsizemb",
|
||||||
|
"default_value": "100",
|
||||||
|
"comment": "Rotate the audit log file once it exceeds this size in megabytes. Set to 0 to disable size-based rotation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "maxage",
|
||||||
|
"default_value": "30",
|
||||||
|
"comment": "Delete rotated audit log files older than this many days. This only applies to the local rotated files, it is not a retention policy. Set to 0 to keep rotated files forever."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "outgoingrequests",
|
"key": "outgoingrequests",
|
||||||
"children": [
|
"children": [
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "40.10.2",
|
"electron": "40.10.5",
|
||||||
"electron-builder": "26.15.0",
|
"electron-builder": "26.15.3",
|
||||||
"unzipper": "0.12.3"
|
"unzipper": "0.12.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "5.2.1"
|
"express": "5.2.1"
|
||||||
|
|
@ -74,11 +74,13 @@
|
||||||
],
|
],
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"minimatch": "^10.2.3",
|
"minimatch": "^10.2.3",
|
||||||
"tar": "^7.5.11",
|
"tar": ">=7.5.16",
|
||||||
"@tootallnate/once": "^3.0.1",
|
"@tootallnate/once": "^3.0.1",
|
||||||
"picomatch": ">=4.0.4",
|
"picomatch": ">=4.0.4",
|
||||||
"tmp": ">=0.2.6",
|
"tmp": ">=0.2.7",
|
||||||
"ip-address": ">=10.1.1"
|
"ip-address": ">=10.1.1",
|
||||||
|
"form-data": ">=4.0.6",
|
||||||
|
"js-yaml": ">=4.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
64
devenv.lock
64
devenv.lock
|
|
@ -16,62 +16,6 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"flake-compat": {
|
|
||||||
"flake": false,
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1767039857,
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "flake-compat",
|
|
||||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "flake-compat",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"git-hooks": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-compat": "flake-compat",
|
|
||||||
"gitignore": "gitignore",
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1772893680,
|
|
||||||
"owner": "cachix",
|
|
||||||
"repo": "git-hooks.nix",
|
|
||||||
"rev": "8baab586afc9c9b57645a734c820e4ac0a604af9",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "cachix",
|
|
||||||
"repo": "git-hooks.nix",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gitignore": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"git-hooks",
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1762808025,
|
|
||||||
"owner": "hercules-ci",
|
|
||||||
"repo": "gitignore.nix",
|
|
||||||
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "hercules-ci",
|
|
||||||
"repo": "gitignore.nix",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs-src": "nixpkgs-src"
|
"nixpkgs-src": "nixpkgs-src"
|
||||||
|
|
@ -125,15 +69,11 @@
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"devenv": "devenv",
|
"devenv": "devenv",
|
||||||
"git-hooks": "git-hooks",
|
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||||
"pre-commit-hooks": [
|
|
||||||
"git-hooks"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
"version": 7
|
"version": 7
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +82,7 @@
|
||||||
"bulma-css-variables": "0.9.33",
|
"bulma-css-variables": "0.9.33",
|
||||||
"change-case": "5.4.4",
|
"change-case": "5.4.4",
|
||||||
"dayjs": "1.11.19",
|
"dayjs": "1.11.19",
|
||||||
"dompurify": "3.4.0",
|
"dompurify": "3.4.11",
|
||||||
"fast-deep-equal": "3.1.3",
|
"fast-deep-equal": "3.1.3",
|
||||||
"flatpickr": "4.6.13",
|
"flatpickr": "4.6.13",
|
||||||
"floating-vue": "5.2.2",
|
"floating-vue": "5.2.2",
|
||||||
|
|
@ -105,41 +105,41 @@
|
||||||
"zhyswan-vuedraggable": "4.1.3"
|
"zhyswan-vuedraggable": "4.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "10.4.0",
|
"@faker-js/faker": "10.5.0",
|
||||||
"@histoire/plugin-screenshot": "1.0.0-beta.1",
|
"@histoire/plugin-screenshot": "1.0.0-beta.1",
|
||||||
"@histoire/plugin-vue": "1.0.0-beta.1",
|
"@histoire/plugin-vue": "1.0.0-beta.1",
|
||||||
"@playwright/test": "1.58.2",
|
"@playwright/test": "1.58.2",
|
||||||
"@sentry/vite-plugin": "3.6.1",
|
"@sentry/vite-plugin": "3.6.1",
|
||||||
"@tailwindcss/vite": "4.3.0",
|
"@tailwindcss/vite": "4.3.1",
|
||||||
"@tsconfig/node24": "24.0.4",
|
"@tsconfig/node24": "24.0.4",
|
||||||
"@types/codemirror": "5.60.17",
|
"@types/codemirror": "5.60.17",
|
||||||
"@types/is-touch-device": "1.0.3",
|
"@types/is-touch-device": "1.0.3",
|
||||||
"@types/node": "24.13.1",
|
"@types/node": "24.13.2",
|
||||||
"@types/sortablejs": "1.15.9",
|
"@types/sortablejs": "1.15.9",
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "8.60.1",
|
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||||
"@typescript-eslint/parser": "8.60.1",
|
"@typescript-eslint/parser": "8.62.0",
|
||||||
"@vitejs/plugin-vue": "6.0.7",
|
"@vitejs/plugin-vue": "6.0.7",
|
||||||
"@vue/eslint-config-typescript": "14.8.0",
|
"@vue/eslint-config-typescript": "14.9.0",
|
||||||
"@vue/test-utils": "2.4.11",
|
"@vue/test-utils": "2.4.11",
|
||||||
"@vue/tsconfig": "0.9.1",
|
"@vue/tsconfig": "0.9.1",
|
||||||
"@vueuse/shared": "14.3.0",
|
"@vueuse/shared": "14.3.0",
|
||||||
"autoprefixer": "10.5.0",
|
"autoprefixer": "10.5.1",
|
||||||
"browserslist": "4.28.2",
|
"browserslist": "4.28.4",
|
||||||
"caniuse-lite": "1.0.30001797",
|
"caniuse-lite": "1.0.30001799",
|
||||||
"csstype": "3.2.3",
|
"csstype": "3.2.3",
|
||||||
"esbuild": "0.28.0",
|
"esbuild": "0.28.1",
|
||||||
"eslint": "9.39.4",
|
"eslint": "9.39.4",
|
||||||
"eslint-plugin-depend": "1.5.0",
|
"eslint-plugin-depend": "1.5.0",
|
||||||
"eslint-plugin-vue": "10.9.2",
|
"eslint-plugin-vue": "10.9.2",
|
||||||
"happy-dom": "20.10.2",
|
"happy-dom": "20.10.6",
|
||||||
"histoire": "1.0.0-beta.1",
|
"histoire": "1.0.0-beta.1",
|
||||||
"otplib": "12.0.1",
|
"otplib": "12.0.1",
|
||||||
"postcss": "8.5.15",
|
"postcss": "8.5.15",
|
||||||
"postcss-easing-gradients": "3.0.1",
|
"postcss-easing-gradients": "3.0.1",
|
||||||
"postcss-html": "1.8.1",
|
"postcss-html": "1.8.1",
|
||||||
"postcss-preset-env": "11.3.0",
|
"postcss-preset-env": "11.3.1",
|
||||||
"rollup": "4.61.1",
|
"rollup": "4.62.2",
|
||||||
"rollup-plugin-visualizer": "6.0.11",
|
"rollup-plugin-visualizer": "6.0.11",
|
||||||
"sass-embedded": "1.100.0",
|
"sass-embedded": "1.100.0",
|
||||||
"stylelint": "17.13.0",
|
"stylelint": "17.13.0",
|
||||||
|
|
@ -147,15 +147,15 @@
|
||||||
"stylelint-config-recommended-vue": "1.6.1",
|
"stylelint-config-recommended-vue": "1.6.1",
|
||||||
"stylelint-config-standard-scss": "17.0.0",
|
"stylelint-config-standard-scss": "17.0.0",
|
||||||
"stylelint-use-logical": "2.1.3",
|
"stylelint-use-logical": "2.1.3",
|
||||||
"tailwindcss": "4.3.0",
|
"tailwindcss": "4.3.1",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"unplugin-inject-preload": "3.0.0",
|
"unplugin-inject-preload": "3.0.0",
|
||||||
"vite": "7.3.5",
|
"vite": "7.3.5",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
"vite-plugin-vue-devtools": "8.1.2",
|
"vite-plugin-vue-devtools": "8.1.3",
|
||||||
"vite-svg-loader": "5.1.1",
|
"vite-svg-loader": "5.1.1",
|
||||||
"vitest": "4.1.8",
|
"vitest": "4.1.9",
|
||||||
"vue-tsc": "3.3.3",
|
"vue-tsc": "3.3.5",
|
||||||
"wait-on": "9.0.10",
|
"wait-on": "9.0.10",
|
||||||
"workbox-cli": "7.4.1",
|
"workbox-cli": "7.4.1",
|
||||||
"ws": "8.21.0"
|
"ws": "8.21.0"
|
||||||
|
|
@ -176,7 +176,13 @@
|
||||||
"flatted": "^3.4.1",
|
"flatted": "^3.4.1",
|
||||||
"ip-address": ">=10.1.1",
|
"ip-address": ">=10.1.1",
|
||||||
"postcss": ">=8.5.10",
|
"postcss": ">=8.5.10",
|
||||||
"tmp": ">=0.2.6"
|
"tmp": ">=0.2.7",
|
||||||
|
"esbuild": ">=0.28.1",
|
||||||
|
"form-data": ">=4.0.6",
|
||||||
|
"markdown-it": ">=14.2.0",
|
||||||
|
"launch-editor": ">=2.14.1",
|
||||||
|
"@babel/core": ">=7.29.6",
|
||||||
|
"js-yaml@4": ">=4.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
|
|
@ -61,6 +61,7 @@ import {useAuthStore} from '@/stores/auth'
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
|
||||||
import {useColorScheme} from '@/composables/useColorScheme'
|
import {useColorScheme} from '@/composables/useColorScheme'
|
||||||
|
import {useTimeTrackingFavicon} from '@/composables/useTimeTrackingFavicon'
|
||||||
import {useBodyClass} from '@/composables/useBodyClass'
|
import {useBodyClass} from '@/composables/useBodyClass'
|
||||||
import QuickAddOverlay from '@/components/quick-actions/QuickAddOverlay.vue'
|
import QuickAddOverlay from '@/components/quick-actions/QuickAddOverlay.vue'
|
||||||
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
|
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
|
||||||
|
|
@ -107,6 +108,7 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
|
||||||
|
|
||||||
setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE)
|
setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE)
|
||||||
useColorScheme()
|
useColorScheme()
|
||||||
|
useTimeTrackingFavicon()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style src="@/styles/tailwind.css" />
|
<style src="@/styles/tailwind.css" />
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,18 @@ describe('DatepickerWithRange predefined ranges', () => {
|
||||||
const last = wrapper.emitted('update:modelValue')?.pop()?.[0]
|
const last = wrapper.emitted('update:modelValue')?.pop()?.[0]
|
||||||
expect(last).toEqual({dateFrom: 'now/M-1M', dateTo: 'now/M'})
|
expect(last).toEqual({dateFrom: 'now/M-1M', dateTo: 'now/M'})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A cleared range (the Custom option) comes back as null via v-model; the
|
||||||
|
// modelValue watcher must coerce it, not call null.toISOString().
|
||||||
|
it('accepts a null modelValue without crashing', async () => {
|
||||||
|
const wrapper = mountPicker()
|
||||||
|
await wrapper.setProps({modelValue: {dateFrom: 'now/w', dateTo: 'now/w+1w'}})
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect((wrapper.vm as any).from).toBe('now/w')
|
||||||
|
|
||||||
|
await wrapper.setProps({modelValue: {dateFrom: null, dateTo: null}})
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect((wrapper.vm as any).from).toBe('')
|
||||||
|
expect((wrapper.vm as any).to).toBe('')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -114,16 +114,17 @@ import DatemathHelp from '@/components/date/DatemathHelp.vue'
|
||||||
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
// null for a side that's been cleared (the Custom option) — emitted, so accepted too.
|
||||||
modelValue: {
|
modelValue: {
|
||||||
dateFrom: Date | string,
|
dateFrom: Date | string | null,
|
||||||
dateTo: Date | string,
|
dateTo: Date | string | null,
|
||||||
},
|
},
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: {
|
'update:modelValue': [value: {
|
||||||
dateFrom: Date | string,
|
dateFrom: Date | string | null,
|
||||||
dateTo: Date | string
|
dateTo: Date | string | null
|
||||||
}]
|
}]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|
@ -149,8 +150,8 @@ const to = ref('')
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
newValue => {
|
newValue => {
|
||||||
from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : newValue.dateFrom.toISOString()
|
from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : (newValue.dateFrom?.toISOString() ?? '')
|
||||||
to.value = typeof newValue.dateTo === 'string' ? newValue.dateTo : newValue.dateTo.toISOString()
|
to.value = typeof newValue.dateTo === 'string' ? newValue.dateTo : (newValue.dateTo?.toISOString() ?? '')
|
||||||
// Only set the date back to flatpickr when it's an actual date.
|
// Only set the date back to flatpickr when it's an actual date.
|
||||||
// Otherwise flatpickr runs in an endless loop and slows down the browser.
|
// Otherwise flatpickr runs in an endless loop and slows down the browser.
|
||||||
const dateFrom = parseDateOrString(from.value, false)
|
const dateFrom = parseDateOrString(from.value, false)
|
||||||
|
|
@ -208,14 +209,22 @@ const customRangeActive = computed<boolean>(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const buttonText = computed<string>(() => {
|
const buttonText = computed<string>(() => {
|
||||||
if (from.value !== '' && to.value !== '') {
|
if (from.value === '' || to.value === '') {
|
||||||
return t('input.datepickerRange.fromto', {
|
return t('task.show.select')
|
||||||
from: from.value,
|
|
||||||
to: to.value,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return t('task.show.select')
|
// Show the preset's name when the range matches one, rather than the raw datemath.
|
||||||
|
const preset = Object.entries(DATE_RANGES).find(
|
||||||
|
([, range]) => from.value === range[0] && to.value === range[1],
|
||||||
|
)
|
||||||
|
if (preset) {
|
||||||
|
return t(`input.datepickerRange.ranges.${preset[0]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('input.datepickerRange.fromto', {
|
||||||
|
from: from.value,
|
||||||
|
to: to.value,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -730,7 +730,7 @@ function focusTaskBar(rowId: string) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement
|
const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement
|
||||||
if (taskBarElement) {
|
if (taskBarElement) {
|
||||||
taskBarElement.focus()
|
taskBarElement.focus({preventScroll: true})
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,15 @@
|
||||||
</ProjectSettingsDropdown>
|
</ProjectSettingsDropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="pageTitle"
|
||||||
|
class="project-title-wrapper"
|
||||||
|
>
|
||||||
|
<span class="project-title">{{ pageTitle }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
|
<TimerBadge />
|
||||||
<OpenQuickActions />
|
<OpenQuickActions />
|
||||||
<Notifications />
|
<Notifications />
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
|
|
@ -121,13 +129,17 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { PERMISSIONS as Permissions } from '@/constants/permissions'
|
import { PERMISSIONS as Permissions } from '@/constants/permissions'
|
||||||
|
import { PRO_FEATURE } from '@/constants/proFeatures'
|
||||||
|
|
||||||
import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdown.vue'
|
import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdown.vue'
|
||||||
import Dropdown from '@/components/misc/Dropdown.vue'
|
import Dropdown from '@/components/misc/Dropdown.vue'
|
||||||
import DropdownItem from '@/components/misc/DropdownItem.vue'
|
import DropdownItem from '@/components/misc/DropdownItem.vue'
|
||||||
import Notifications from '@/components/notifications/Notifications.vue'
|
import Notifications from '@/components/notifications/Notifications.vue'
|
||||||
|
import TimerBadge from '@/components/time-tracking/TimerBadge.vue'
|
||||||
import Logo from '@/components/home/Logo.vue'
|
import Logo from '@/components/home/Logo.vue'
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
import MenuButton from '@/components/home/MenuButton.vue'
|
import MenuButton from '@/components/home/MenuButton.vue'
|
||||||
|
|
@ -151,12 +163,20 @@ const background = computed(() => baseStore.background)
|
||||||
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxPermission !== null && baseStore.currentProject?.maxPermission !== undefined && baseStore.currentProject.maxPermission > Permissions.READ)
|
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxPermission !== null && baseStore.currentProject?.maxPermission !== undefined && baseStore.currentProject.maxPermission > Permissions.READ)
|
||||||
const menuActive = computed(() => baseStore.menuActive)
|
const menuActive = computed(() => baseStore.menuActive)
|
||||||
|
|
||||||
|
// Standalone pages (no project) surface their route's title in the header.
|
||||||
|
const route = useRoute()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
const title = route.meta.title as string | undefined
|
||||||
|
return title ? t(title) : ''
|
||||||
|
})
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
||||||
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
|
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
|
||||||
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled('admin_panel'))
|
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.ADMIN_PANEL))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,14 @@
|
||||||
{{ $t('team.title') }}
|
{{ $t('team.title') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li v-if="timeTrackingEnabled">
|
||||||
|
<RouterLink :to="{ name: 'time-tracking'}">
|
||||||
|
<span class="menu-item-icon icon">
|
||||||
|
<Icon :icon="['far', 'clock']" />
|
||||||
|
</span>
|
||||||
|
{{ $t('timeTracking.title') }}
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
</menu>
|
</menu>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -133,12 +141,17 @@ import Loading from '@/components/misc/Loading.vue'
|
||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
import {useConfigStore} from '@/stores/config'
|
||||||
|
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
import {useSidebarResize} from '@/composables/useSidebarResize'
|
import {useSidebarResize} from '@/composables/useSidebarResize'
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
|
const timeTrackingEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING))
|
||||||
|
|
||||||
const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize()
|
const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@
|
||||||
:disabled="disabled || undefined"
|
:disabled="disabled || undefined"
|
||||||
@click.stop="toggleDatePopup"
|
@click.stop="toggleDatePopup"
|
||||||
>
|
>
|
||||||
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
|
<i v-if="date === null && emptyLabel !== ''">{{ emptyLabel }}</i>
|
||||||
|
<template v-else>
|
||||||
|
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
|
||||||
|
</template>
|
||||||
</SimpleButton>
|
</SimpleButton>
|
||||||
|
|
||||||
<CustomTransition name="fade">
|
<CustomTransition name="fade">
|
||||||
|
|
@ -16,6 +19,7 @@
|
||||||
>
|
>
|
||||||
<DatepickerInline
|
<DatepickerInline
|
||||||
v-model="date"
|
v-model="date"
|
||||||
|
:show-shortcuts="showShortcuts"
|
||||||
@update:modelValue="updateData"
|
@update:modelValue="updateData"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -48,12 +52,17 @@ const props = withDefaults(defineProps<{
|
||||||
modelValue: Date | null | string,
|
modelValue: Date | null | string,
|
||||||
chooseDateLabel?: string,
|
chooseDateLabel?: string,
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
|
showShortcuts?: boolean,
|
||||||
|
// When the value is null, show this (italic) instead of chooseDateLabel.
|
||||||
|
emptyLabel?: string,
|
||||||
}>(), {
|
}>(), {
|
||||||
chooseDateLabel: () => {
|
chooseDateLabel: () => {
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
return t('input.datepicker.chooseDate')
|
return t('input.datepicker.chooseDate')
|
||||||
},
|
},
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
showShortcuts: true,
|
||||||
|
emptyLabel: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,68 @@
|
||||||
<template>
|
<template>
|
||||||
<BaseButton
|
<template v-if="showShortcuts">
|
||||||
v-if="(new Date()).getHours() < 21"
|
<BaseButton
|
||||||
class="datepicker__quick-select-date"
|
v-if="(new Date()).getHours() < 21"
|
||||||
@click.stop="setDate('today')"
|
class="datepicker__quick-select-date"
|
||||||
>
|
@click.stop="setDate('today')"
|
||||||
<span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span>
|
>
|
||||||
<span class="text">
|
<span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span>
|
||||||
<span>{{ $t('input.datepicker.today') }}</span>
|
<span class="text">
|
||||||
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
<span>{{ $t('input.datepicker.today') }}</span>
|
||||||
</span>
|
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
||||||
</BaseButton>
|
</span>
|
||||||
<BaseButton
|
</BaseButton>
|
||||||
class="datepicker__quick-select-date"
|
<BaseButton
|
||||||
@click.stop="setDate('tomorrow')"
|
class="datepicker__quick-select-date"
|
||||||
>
|
@click.stop="setDate('tomorrow')"
|
||||||
<span class="icon"><Icon :icon="['far', 'sun']" /></span>
|
>
|
||||||
<span class="text">
|
<span class="icon"><Icon :icon="['far', 'sun']" /></span>
|
||||||
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
<span class="text">
|
||||||
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
||||||
</span>
|
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
||||||
</BaseButton>
|
</span>
|
||||||
<BaseButton
|
</BaseButton>
|
||||||
class="datepicker__quick-select-date"
|
<BaseButton
|
||||||
@click.stop="setDate('nextMonday')"
|
class="datepicker__quick-select-date"
|
||||||
>
|
@click.stop="setDate('nextMonday')"
|
||||||
<span class="icon"><Icon icon="coffee" /></span>
|
>
|
||||||
<span class="text">
|
<span class="icon"><Icon icon="coffee" /></span>
|
||||||
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
<span class="text">
|
||||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
||||||
</span>
|
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
||||||
</BaseButton>
|
</span>
|
||||||
<BaseButton
|
</BaseButton>
|
||||||
v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)"
|
<BaseButton
|
||||||
class="datepicker__quick-select-date"
|
v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)"
|
||||||
@click.stop="setDate('thisWeekend')"
|
class="datepicker__quick-select-date"
|
||||||
>
|
@click.stop="setDate('thisWeekend')"
|
||||||
<span class="icon"><Icon icon="cocktail" /></span>
|
>
|
||||||
<span class="text">
|
<span class="icon"><Icon icon="cocktail" /></span>
|
||||||
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
<span class="text">
|
||||||
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
||||||
</span>
|
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
||||||
</BaseButton>
|
</span>
|
||||||
<BaseButton
|
</BaseButton>
|
||||||
class="datepicker__quick-select-date"
|
<BaseButton
|
||||||
@click.stop="setDate('laterThisWeek')"
|
class="datepicker__quick-select-date"
|
||||||
>
|
@click.stop="setDate('laterThisWeek')"
|
||||||
<span class="icon"><Icon icon="chess-knight" /></span>
|
>
|
||||||
<span class="text">
|
<span class="icon"><Icon icon="chess-knight" /></span>
|
||||||
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
<span class="text">
|
||||||
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
||||||
</span>
|
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
||||||
</BaseButton>
|
</span>
|
||||||
<BaseButton
|
</BaseButton>
|
||||||
class="datepicker__quick-select-date"
|
<BaseButton
|
||||||
@click.stop="setDate('nextWeek')"
|
class="datepicker__quick-select-date"
|
||||||
>
|
@click.stop="setDate('nextWeek')"
|
||||||
<span class="icon"><Icon icon="forward" /></span>
|
>
|
||||||
<span class="text">
|
<span class="icon"><Icon icon="forward" /></span>
|
||||||
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
<span class="text">
|
||||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
||||||
</span>
|
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
||||||
</BaseButton>
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="flatpickr-container">
|
<div class="flatpickr-container">
|
||||||
<flat-pickr
|
<flat-pickr
|
||||||
|
|
@ -87,9 +89,12 @@ import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
||||||
import {useTimeFormat} from '@/composables/useTimeFormat'
|
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||||
import {TIME_FORMAT} from '@/constants/timeFormat'
|
import {TIME_FORMAT} from '@/constants/timeFormat'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
modelValue: Date | null | string
|
modelValue: Date | null | string
|
||||||
}>()
|
showShortcuts?: boolean
|
||||||
|
}>(), {
|
||||||
|
showShortcuts: true,
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [Date | null],
|
'update:modelValue': [Date | null],
|
||||||
|
|
|
||||||
|
|
@ -722,7 +722,7 @@ async function addImage(event: Event) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = await inputPrompt(event.target.getBoundingClientRect())
|
const url = await inputPrompt(event.target.getBoundingClientRect(), '', editor.value)
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
editor.value?.chain().focus().setImage({src: url}).run()
|
editor.value?.chain().focus().setImage({src: url}).run()
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {PluginKey, type EditorState} from '@tiptap/pm/state'
|
||||||
|
|
||||||
import EmojiList from './EmojiList.vue'
|
import EmojiList from './EmojiList.vue'
|
||||||
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
|
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
|
||||||
|
import {getPopupContainer} from '../popupContainer'
|
||||||
|
|
||||||
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
|
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
|
||||||
|
|
||||||
|
|
@ -78,7 +79,7 @@ export default function emojiSuggestionSetup() {
|
||||||
popupElement.style.left = '0'
|
popupElement.style.left = '0'
|
||||||
popupElement.style.zIndex = '4700'
|
popupElement.style.zIndex = '4700'
|
||||||
popupElement.appendChild(component.element!)
|
popupElement.appendChild(component.element!)
|
||||||
document.body.appendChild(popupElement)
|
getPopupContainer(props.editor).appendChild(popupElement)
|
||||||
|
|
||||||
const rect = props.clientRect()
|
const rect = props.clientRect()
|
||||||
if (!rect) {
|
if (!rect) {
|
||||||
|
|
@ -108,7 +109,7 @@ export default function emojiSuggestionSetup() {
|
||||||
cleanupFloating = null
|
cleanupFloating = null
|
||||||
}
|
}
|
||||||
if (popupElement) {
|
if (popupElement) {
|
||||||
document.body.removeChild(popupElement)
|
popupElement.remove()
|
||||||
popupElement = null
|
popupElement = null
|
||||||
}
|
}
|
||||||
component?.destroy()
|
component?.destroy()
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import inputPrompt from '@/helpers/inputPrompt'
|
||||||
|
|
||||||
export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) {
|
export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) {
|
||||||
const previousUrl = editor?.getAttributes('link').href || ''
|
const previousUrl = editor?.getAttributes('link').href || ''
|
||||||
const url = await inputPrompt(pos, previousUrl)
|
const url = await inputPrompt(pos, previousUrl, editor ?? undefined)
|
||||||
|
|
||||||
// empty
|
// empty
|
||||||
if (url === '') {
|
if (url === '') {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {library} from '@fortawesome/fontawesome-svg-core'
|
import {library} from '@fortawesome/fontawesome-svg-core'
|
||||||
import {
|
import {
|
||||||
faAlignLeft,
|
faAlignLeft,
|
||||||
|
faAngleLeft,
|
||||||
faAngleRight,
|
faAngleRight,
|
||||||
faAnglesUp,
|
faAnglesUp,
|
||||||
faArchive,
|
faArchive,
|
||||||
|
|
@ -121,6 +122,7 @@ library.add(faCode)
|
||||||
library.add(faQuoteRight)
|
library.add(faQuoteRight)
|
||||||
library.add(faListUl)
|
library.add(faListUl)
|
||||||
library.add(faAlignLeft)
|
library.add(faAlignLeft)
|
||||||
|
library.add(faAngleLeft)
|
||||||
library.add(faAngleRight)
|
library.add(faAngleRight)
|
||||||
library.add(faArchive)
|
library.add(faArchive)
|
||||||
library.add(faArrowLeft)
|
library.add(faArrowLeft)
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ const props = withDefaults(defineProps<{
|
||||||
enabled?: boolean,
|
enabled?: boolean,
|
||||||
overflow?: boolean,
|
overflow?: boolean,
|
||||||
wide?: boolean,
|
wide?: boolean,
|
||||||
variant?: 'default' | 'hint-modal' | 'scrolling',
|
variant?: 'default' | 'hint-modal' | 'scrolling' | 'top',
|
||||||
}>(), {
|
}>(), {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
overflow: false,
|
overflow: false,
|
||||||
|
|
@ -211,7 +211,13 @@ $modal-width: 1024px;
|
||||||
// Reset UA dialog styles
|
// Reset UA dialog styles
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
// The scrim lives on the dialog element, not on ::backdrop: Chromium
|
||||||
|
// intermittently stops painting a styled ::backdrop (e.g. after the
|
||||||
|
// dialog's subtree re-renders, or while display is transitioned) even
|
||||||
|
// though getComputedStyle still reports the color. The dialog fills the
|
||||||
|
// viewport anyway, and its opacity transition fades the scrim with it —
|
||||||
|
// same as the old div-based .modal-mask.
|
||||||
|
background: rgba(0, 0, 0, .8);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
// Fill viewport
|
// Fill viewport
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
@ -221,10 +227,12 @@ $modal-width: 1024px;
|
||||||
max-inline-size: 100%;
|
max-inline-size: 100%;
|
||||||
max-block-size: 100%;
|
max-block-size: 100%;
|
||||||
|
|
||||||
// Transitions
|
// Transitions. No display/allow-discrete transition needed: the close
|
||||||
|
// fade runs while the dialog is still [open] (data-closing + timer in
|
||||||
|
// closeDialog), and transitioning display triggers the Chromium paint
|
||||||
|
// bug above.
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 150ms ease,
|
transition: opacity 150ms ease;
|
||||||
display 150ms ease allow-discrete;
|
|
||||||
|
|
||||||
&[open]:not([data-closing]) {
|
&[open]:not([data-closing]) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
@ -236,16 +244,11 @@ $modal-width: 1024px;
|
||||||
|
|
||||||
&::backdrop {
|
&::backdrop {
|
||||||
background-color: rgba(0, 0, 0, 0);
|
background-color: rgba(0, 0, 0, 0);
|
||||||
transition: background-color 150ms ease,
|
|
||||||
display 150ms ease allow-discrete;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&[open]:not([data-closing])::backdrop {
|
// in quick-add mode the Electron window itself is the overlay — no scrim
|
||||||
background-color: rgba(0, 0, 0, .8);
|
&:has(.is-quick-add-mode) {
|
||||||
|
background: transparent;
|
||||||
@starting-style {
|
|
||||||
background-color: rgba(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,13 +264,20 @@ $modal-width: 1024px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.default .modal-content,
|
.default .modal-content,
|
||||||
.hint-modal .modal-content {
|
.hint-modal .modal-content,
|
||||||
|
.top .modal-content {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
// fine to use top/left since we're only using this to position it centered
|
// fine to use top/left since we're only using this to position it centered
|
||||||
inset-block-start: 50%;
|
inset-block-start: 50%;
|
||||||
inset-inline-start: 50%;
|
inset-inline-start: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
|
// Cap centered content to the viewport and scroll inside it. Without this a
|
||||||
|
// taller-than-viewport modal centres its top edge above the viewport, where
|
||||||
|
// the container's overflow can't scroll to it (the .top variant overrides
|
||||||
|
// both values below).
|
||||||
|
max-block-size: calc(100dvh - 2rem);
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
[dir="rtl"] & {
|
[dir="rtl"] & {
|
||||||
transform: translate(50%, -50%);
|
transform: translate(50%, -50%);
|
||||||
|
|
@ -277,6 +287,9 @@ $modal-width: 1024px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
position: static;
|
position: static;
|
||||||
transform: none;
|
transform: none;
|
||||||
|
// the fullscreen mobile layout flows and scrolls in .modal-container
|
||||||
|
max-block-size: none;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
|
|
@ -289,11 +302,31 @@ $modal-width: 1024px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// anchored below the top edge instead of centered, used for QuickActions
|
||||||
|
.top .modal-content {
|
||||||
|
inset-block-start: 3rem;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
max-block-size: calc(100dvh - 6rem);
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
[dir="rtl"] & {
|
||||||
|
transform: translate(50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the fullscreen mobile layout flows and scrolls in .modal-container
|
||||||
|
@media screen and (max-width: $tablet) {
|
||||||
|
transform: none;
|
||||||
|
max-block-size: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Default width for centered modals. Scoped with :not(.is-wide) so the
|
// Default width for centered modals. Scoped with :not(.is-wide) so the
|
||||||
// `wide` prop can still expand the modal (the .is-wide rule below would
|
// `wide` prop can still expand the modal (the .is-wide rule below would
|
||||||
// otherwise be outranked by .default .modal-content's specificity).
|
// otherwise be outranked by .default .modal-content's specificity).
|
||||||
.default .modal-content:not(.is-wide),
|
.default .modal-content:not(.is-wide),
|
||||||
.hint-modal .modal-content:not(.is-wide) {
|
.hint-modal .modal-content:not(.is-wide),
|
||||||
|
.top .modal-content:not(.is-wide) {
|
||||||
inline-size: calc(100% - 2rem);
|
inline-size: calc(100% - 2rem);
|
||||||
max-inline-size: 640px;
|
max-inline-size: 640px;
|
||||||
|
|
||||||
|
|
@ -403,6 +436,7 @@ $modal-width: 1024px;
|
||||||
block-size: auto;
|
block-size: auto;
|
||||||
max-inline-size: none;
|
max-inline-size: none;
|
||||||
max-block-size: none;
|
max-block-size: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
&::backdrop {
|
&::backdrop {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,14 @@
|
||||||
<template #default>
|
<template #default>
|
||||||
<Card :has-content="false">
|
<Card :has-content="false">
|
||||||
<div class="gantt-options">
|
<div class="gantt-options">
|
||||||
<FormField :label="$t('project.gantt.range')">
|
<FormField :label="$t('misc.dateRange')">
|
||||||
<Foo
|
<Foo
|
||||||
id="range"
|
id="range"
|
||||||
ref="flatPickerEl"
|
ref="flatPickerEl"
|
||||||
v-model="flatPickerDateRange"
|
v-model="flatPickerDateRange"
|
||||||
:config="flatPickerConfig"
|
:config="flatPickerConfig"
|
||||||
class="input"
|
class="input"
|
||||||
:placeholder="$t('project.gantt.range')"
|
:placeholder="$t('misc.dateRange')"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@
|
||||||
@click.stop="showSetLimitInput = true"
|
@click.stop="showSetLimitInput = true"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('project.kanban.noLimit')})
|
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('misc.notSet')})
|
||||||
}}
|
}}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
<Modal
|
<Modal
|
||||||
:enabled="active"
|
:enabled="active"
|
||||||
:overflow="isNewTaskCommand"
|
:overflow="isNewTaskCommand"
|
||||||
|
variant="top"
|
||||||
@close="closeQuickActions"
|
@close="closeQuickActions"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -704,15 +705,16 @@ function reset() {
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.quick-actions {
|
.quick-actions {
|
||||||
|
// global Bulma .card styles are gone (ported into Card.vue, scoped),
|
||||||
|
// so this bare .card div needs its own card visuals
|
||||||
|
background-color: var(--white);
|
||||||
|
border-radius: $radius;
|
||||||
|
border: 1px solid var(--card-border-color);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
color: var(--text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
justify-content: flex-start !important;
|
justify-content: flex-start !important;
|
||||||
|
|
||||||
// FIXME: changed position should be an option of the modal
|
|
||||||
:deep(.modal-content) {
|
|
||||||
inset-block-start: 3rem;
|
|
||||||
transform: translate(-50%, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-quick-add-mode {
|
&.is-quick-add-mode {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
rows="1"
|
rows="1"
|
||||||
@keydown="resetEmptyTitleError"
|
@keydown="resetEmptyTitleError"
|
||||||
@keydown.enter="handleEnter"
|
@keydown.enter="handleEnter"
|
||||||
|
@keydown.esc="blurTaskInput"
|
||||||
/>
|
/>
|
||||||
<QuickAddMagic
|
<QuickAddMagic
|
||||||
:highlight-hint-icon="taskAddHovered"
|
:highlight-hint-icon="taskAddHovered"
|
||||||
|
|
@ -282,6 +283,10 @@ function focusTaskInput() {
|
||||||
newTaskInput.value?.focus()
|
newTaskInput.value?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function blurTaskInput() {
|
||||||
|
newTaskInput.value?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
focusTaskInput,
|
focusTaskInput,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@
|
||||||
</XButton>
|
</XButton>
|
||||||
|
|
||||||
<!-- Dropzone -->
|
<!-- Dropzone -->
|
||||||
<Teleport to="body">
|
<Teleport :to="dropzoneTeleportTarget">
|
||||||
<div
|
<div
|
||||||
v-if="editEnabled"
|
v-if="editEnabled"
|
||||||
:class="{hidden: !showDropzone}"
|
:class="{hidden: !showDropzone}"
|
||||||
|
|
@ -185,7 +185,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, shallowReactive, computed, watch} from 'vue'
|
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount} from 'vue'
|
||||||
import {useDropZone} from '@vueuse/core'
|
import {useDropZone} from '@vueuse/core'
|
||||||
|
|
||||||
import User from '@/components/misc/User.vue'
|
import User from '@/components/misc/User.vue'
|
||||||
|
|
@ -322,6 +322,34 @@ const showDropzone = computed(() =>
|
||||||
props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value,
|
props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A <dialog> opened with showModal() (e.g. the Kanban task detail) renders in
|
||||||
|
// the browser's top layer, so the full-screen dropzone overlay teleported to
|
||||||
|
// <body> would paint behind it regardless of z-index. Teleport it into the
|
||||||
|
// topmost open dialog instead, mirroring Notification.vue.
|
||||||
|
const dropzoneTeleportTarget = ref<string | HTMLElement>('body')
|
||||||
|
let dialogObserver: MutationObserver | null = null
|
||||||
|
|
||||||
|
function syncDropzoneTeleportTarget() {
|
||||||
|
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
|
||||||
|
dropzoneTeleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncDropzoneTeleportTarget()
|
||||||
|
dialogObserver = new MutationObserver(syncDropzoneTeleportTarget)
|
||||||
|
dialogObserver.observe(document.body, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['open'],
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
dialogObserver?.disconnect()
|
||||||
|
dialogObserver = null
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => props.editEnabled, enabled => {
|
watch(() => props.editEnabled, enabled => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
resetDragState()
|
resetDragState()
|
||||||
|
|
@ -478,7 +506,7 @@ defineExpose({
|
||||||
inset-inline-start: 0;
|
inset-inline-start: 0;
|
||||||
inset-block-end: 0;
|
inset-block-end: 0;
|
||||||
inset-inline-end: 0;
|
inset-inline-end: 0;
|
||||||
z-index: 4001; // modal z-index is 4000
|
z-index: 4001; // above app chrome when teleported to body (no modal open)
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
&.hidden {
|
&.hidden {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
v-if="editEnabled && Object.keys(relatedTasks).length > 0"
|
v-if="editEnabled && Object.keys(relatedTasks).length > 0"
|
||||||
id="showRelatedTasksFormButton"
|
id="showRelatedTasksFormButton"
|
||||||
v-tooltip="$t('task.relation.add')"
|
v-tooltip="$t('task.relation.add')"
|
||||||
class="is-pulled-right add-task-relation-button d-print-none"
|
class="is-pulled-end add-task-relation-button d-print-none"
|
||||||
:class="{'is-active': showNewRelationForm}"
|
:class="{'is-active': showNewRelationForm}"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
<template>
|
||||||
|
<div class="task-time-tracking">
|
||||||
|
<XButton
|
||||||
|
v-if="entries.length > 0"
|
||||||
|
v-tooltip="$t('timeTracking.logTime')"
|
||||||
|
v-cy="'addTaskTimeEntry'"
|
||||||
|
class="is-pulled-right d-print-none"
|
||||||
|
:class="{'is-active': showForm}"
|
||||||
|
variant="secondary"
|
||||||
|
icon="plus"
|
||||||
|
:shadow="false"
|
||||||
|
@click="showForm = !showForm"
|
||||||
|
/>
|
||||||
|
<h3 class="title is-5">
|
||||||
|
{{ $t('timeTracking.title') }}
|
||||||
|
</h3>
|
||||||
|
<TimeEntryForm
|
||||||
|
v-if="formVisible"
|
||||||
|
:task-id="taskId"
|
||||||
|
:entry="editingEntry"
|
||||||
|
:recent-entries="entries"
|
||||||
|
@saved="onSaved"
|
||||||
|
@cancel="editingEntry = null"
|
||||||
|
/>
|
||||||
|
<TimeEntryList
|
||||||
|
class="mbs-4"
|
||||||
|
:entries="entries"
|
||||||
|
:card="false"
|
||||||
|
:empty-text="$t('timeTracking.list.emptyTask')"
|
||||||
|
hide-label-column
|
||||||
|
@edit="editingEntry = $event"
|
||||||
|
@delete="onDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref, computed, watch} from 'vue'
|
||||||
|
|
||||||
|
import TimeEntryForm from '@/components/time-tracking/TimeEntryForm.vue'
|
||||||
|
import TimeEntryList from '@/components/time-tracking/TimeEntryList.vue'
|
||||||
|
|
||||||
|
import {useTimeEntryService} from '@/services/timeEntry'
|
||||||
|
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||||
|
|
||||||
|
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
taskId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const timeTrackingStore = useTimeTrackingStore()
|
||||||
|
const entries = ref<ITimeEntry[]>([])
|
||||||
|
const editingEntry = ref<ITimeEntry | null>(null)
|
||||||
|
const showForm = ref(false)
|
||||||
|
|
||||||
|
// Like related tasks: the form is implicit when empty, otherwise behind the +.
|
||||||
|
const formVisible = computed(() => entries.value.length === 0 || showForm.value || editingEntry.value !== null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const {items} = await useTimeEntryService().getAll({
|
||||||
|
filter: `task_id = ${props.taskId}`,
|
||||||
|
perPage: 250,
|
||||||
|
})
|
||||||
|
entries.value = items
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
editingEntry.value = null
|
||||||
|
showForm.value = false
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(id: number) {
|
||||||
|
await timeTrackingStore.removeEntry(id)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.taskId, load, {immediate: true})
|
||||||
|
// The header badge can start/stop the timer without going through this form;
|
||||||
|
// reload so the row reflects the stop (its new end time).
|
||||||
|
watch(() => timeTrackingStore.activeTimer, load)
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
<template>
|
||||||
|
<form
|
||||||
|
ref="formEl"
|
||||||
|
v-cy="'timeEntryForm'"
|
||||||
|
class="time-entry-form"
|
||||||
|
@submit.prevent="saveEntry"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="taskId === undefined"
|
||||||
|
class="field-columns"
|
||||||
|
>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ $t('task.attributes.project') }}</label>
|
||||||
|
<ProjectSearch v-model="selectedProject" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ $t('timeTracking.form.task') }}</label>
|
||||||
|
<Multiselect
|
||||||
|
v-model="selectedTask"
|
||||||
|
:placeholder="$t('timeTracking.form.taskSearch')"
|
||||||
|
:loading="taskService.loading"
|
||||||
|
:search-results="foundTasks"
|
||||||
|
label="title"
|
||||||
|
@search="findTasks"
|
||||||
|
>
|
||||||
|
<template #searchResult="{option}">
|
||||||
|
{{ option.title }}
|
||||||
|
</template>
|
||||||
|
</Multiselect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ $t('task.comment.comment') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="comment"
|
||||||
|
v-cy="'timeEntryComment'"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
:placeholder="$t('timeTracking.form.commentPlaceholder')"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field is-grouped from-to-row">
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<label class="label">{{ $t('input.datepickerRange.from') }}</label>
|
||||||
|
<Datepicker
|
||||||
|
v-model="from"
|
||||||
|
:show-shortcuts="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="control is-expanded">
|
||||||
|
<label class="label">{{ $t('input.datepickerRange.to') }}</label>
|
||||||
|
<Datepicker
|
||||||
|
v-model="to"
|
||||||
|
:show-shortcuts="false"
|
||||||
|
:empty-label="$t('misc.notSet')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('timeTracking.form.smartFill')"
|
||||||
|
v-cy="'smartFill'"
|
||||||
|
class="smart-fill"
|
||||||
|
:aria-label="$t('timeTracking.form.smartFill')"
|
||||||
|
@click="smartFill"
|
||||||
|
>
|
||||||
|
<Icon :icon="['far', 'clock']" />
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field form-actions">
|
||||||
|
<template v-if="isEditing">
|
||||||
|
<XButton
|
||||||
|
v-cy="'updateTimeEntry'"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
:loading="isSaving"
|
||||||
|
@click="saveEntry"
|
||||||
|
>
|
||||||
|
{{ $t('timeTracking.form.update') }}
|
||||||
|
</XButton>
|
||||||
|
<XButton
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="isSaving"
|
||||||
|
@click="cancelEdit"
|
||||||
|
>
|
||||||
|
{{ $t('misc.cancel') }}
|
||||||
|
</XButton>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<XButton
|
||||||
|
v-cy="'saveTimeEntry'"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
:loading="isSaving"
|
||||||
|
@click="saveEntry"
|
||||||
|
>
|
||||||
|
{{ $t('timeTracking.form.save') }}
|
||||||
|
</XButton>
|
||||||
|
<XButton
|
||||||
|
v-cy="'startTimer'"
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
:loading="isSaving"
|
||||||
|
@click="startTimer"
|
||||||
|
>
|
||||||
|
{{ $t('timeTracking.form.startTimer') }}
|
||||||
|
</XButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref, computed, shallowReactive, watch, nextTick} from 'vue'
|
||||||
|
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
import Multiselect from '@/components/input/Multiselect.vue'
|
||||||
|
import Datepicker from '@/components/input/Datepicker.vue'
|
||||||
|
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
|
||||||
|
|
||||||
|
import TaskService from '@/services/task'
|
||||||
|
import TaskModel from '@/models/task'
|
||||||
|
import {smartFillStart} from '@/helpers/time/smartFillStart'
|
||||||
|
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||||
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
|
||||||
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
// When set, the entry is locked to this task and the project/task pickers are hidden.
|
||||||
|
taskId?: number
|
||||||
|
// When set, the form edits this entry (Update + Cancel) instead of creating.
|
||||||
|
entry?: ITimeEntry | null
|
||||||
|
// Entries the smart-clock looks at to continue from the last one's end.
|
||||||
|
recentEntries?: ITimeEntry[]
|
||||||
|
}>(), {
|
||||||
|
taskId: undefined,
|
||||||
|
entry: undefined,
|
||||||
|
recentEntries: () => [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
saved: []
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const timeTrackingStore = useTimeTrackingStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
|
const isEditing = computed(() => props.entry != null)
|
||||||
|
|
||||||
|
const formEl = ref<HTMLFormElement | null>(null)
|
||||||
|
const selectedProject = ref<IProject | null>(null)
|
||||||
|
const selectedTask = ref<ITask | null>(null)
|
||||||
|
const from = ref<Date | null>(new Date())
|
||||||
|
const to = ref<Date | null>(null)
|
||||||
|
const comment = ref('')
|
||||||
|
const isSaving = ref(false)
|
||||||
|
|
||||||
|
// Task and project are mutually exclusive (XOR) — selecting one clears the other,
|
||||||
|
// so applyTarget never picks a stale target the user has since changed.
|
||||||
|
watch(selectedTask, task => {
|
||||||
|
if (task !== null) {
|
||||||
|
selectedProject.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
watch(selectedProject, project => {
|
||||||
|
if (project !== null) {
|
||||||
|
selectedTask.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const taskService = shallowReactive(new TaskService())
|
||||||
|
const foundTasks = ref<ITask[]>([])
|
||||||
|
async function findTasks(query: string) {
|
||||||
|
if (query === '') {
|
||||||
|
foundTasks.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await taskService.getAll({}, {s: query, sort_by: 'done'}) as ITask[]
|
||||||
|
foundTasks.value = selectedProject.value === null
|
||||||
|
? result
|
||||||
|
: result.filter(task => task.projectId === selectedProject.value?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
// In edit mode the entry already has a valid container; an update that sends
|
||||||
|
// neither keeps it, so don't block submit if the prefill lookup failed.
|
||||||
|
isEditing.value || props.taskId !== undefined || selectedTask.value !== null || selectedProject.value !== null,
|
||||||
|
)
|
||||||
|
|
||||||
|
function smartFill() {
|
||||||
|
from.value = smartFillStart(
|
||||||
|
props.recentEntries,
|
||||||
|
authStore.settings.frontendSettings.timeTrackingDefaultStart ?? '09:00',
|
||||||
|
new Date(),
|
||||||
|
)
|
||||||
|
to.value = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whichever of task / project is set lands on the payload (XOR — enforced by canSubmit).
|
||||||
|
function applyTarget(payload: Partial<ITimeEntry>) {
|
||||||
|
if (props.taskId !== undefined) {
|
||||||
|
payload.taskId = props.taskId
|
||||||
|
} else if (selectedTask.value !== null) {
|
||||||
|
payload.taskId = selectedTask.value.id
|
||||||
|
} else if (selectedProject.value !== null) {
|
||||||
|
payload.projectId = selectedProject.value.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(includeEnd: boolean): Partial<ITimeEntry> {
|
||||||
|
const payload: Partial<ITimeEntry> = {
|
||||||
|
comment: comment.value,
|
||||||
|
startTime: from.value ?? new Date(),
|
||||||
|
}
|
||||||
|
applyTarget(payload)
|
||||||
|
// Saving a manual entry always has an end (an empty "To" means "until now");
|
||||||
|
// only the Start-timer path omits it to create a running timer.
|
||||||
|
if (includeEnd) {
|
||||||
|
payload.endTime = to.value ?? new Date()
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
selectedTask.value = null
|
||||||
|
selectedProject.value = null
|
||||||
|
comment.value = ''
|
||||||
|
from.value = new Date()
|
||||||
|
to.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefill from the entry being edited; a null entry returns the form to create mode.
|
||||||
|
watch(() => props.entry, async entry => {
|
||||||
|
if (entry == null) {
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
comment.value = entry.comment
|
||||||
|
from.value = entry.startTime
|
||||||
|
to.value = entry.endTime
|
||||||
|
// Bring the form into view — the edit button may be far down the list.
|
||||||
|
await nextTick()
|
||||||
|
formEl.value?.scrollIntoView({behavior: 'smooth', block: 'center'})
|
||||||
|
if (props.taskId !== undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (entry.taskId > 0) {
|
||||||
|
selectedProject.value = null
|
||||||
|
try {
|
||||||
|
selectedTask.value = await taskService.get(new TaskModel({id: entry.taskId})) as ITask
|
||||||
|
} catch {
|
||||||
|
selectedTask.value = null
|
||||||
|
}
|
||||||
|
} else if (entry.projectId > 0) {
|
||||||
|
selectedTask.value = null
|
||||||
|
selectedProject.value = (projectStore.projects[entry.projectId] as IProject) ?? null
|
||||||
|
}
|
||||||
|
}, {immediate: true})
|
||||||
|
|
||||||
|
async function submit(includeEnd: boolean) {
|
||||||
|
if (!canSubmit.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isSaving.value = true
|
||||||
|
try {
|
||||||
|
const payload = buildPayload(includeEnd)
|
||||||
|
// A started timer begins now (click time), not when the form first loaded.
|
||||||
|
if (!includeEnd) {
|
||||||
|
payload.startTime = new Date()
|
||||||
|
}
|
||||||
|
await timeTrackingStore.createEntry(payload)
|
||||||
|
reset()
|
||||||
|
emit('saved')
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitUpdate() {
|
||||||
|
const entry = props.entry
|
||||||
|
if (!canSubmit.value || entry == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isSaving.value = true
|
||||||
|
try {
|
||||||
|
const payload: Partial<ITimeEntry> & {id: number} = {
|
||||||
|
id: entry.id,
|
||||||
|
comment: comment.value,
|
||||||
|
startTime: from.value ?? entry.startTime,
|
||||||
|
// A running entry stays running (null); a completed one can't be reopened,
|
||||||
|
// so keep its end if "To" was cleared (the API rejects clearing it).
|
||||||
|
endTime: entry.endTime === null ? to.value : (to.value ?? entry.endTime),
|
||||||
|
taskId: 0,
|
||||||
|
projectId: 0,
|
||||||
|
}
|
||||||
|
applyTarget(payload)
|
||||||
|
await timeTrackingStore.updateEntry(payload)
|
||||||
|
emit('saved')
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveEntry = () => (isEditing.value ? submitUpdate() : submit(true))
|
||||||
|
const startTimer = () => submit(false)
|
||||||
|
function cancelEdit() {
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.field-columns {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
> .field {
|
||||||
|
flex: 1;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.from-to-row {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-fill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
block-size: 2.5em;
|
||||||
|
inline-size: 2.5em;
|
||||||
|
border-radius: $radius;
|
||||||
|
color: var(--primary);
|
||||||
|
transition: background-color $transition;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--grey-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,247 @@
|
||||||
|
<template>
|
||||||
|
<p
|
||||||
|
v-if="rows.length === 0"
|
||||||
|
class="has-text-centered has-text-grey is-italic"
|
||||||
|
>
|
||||||
|
{{ emptyText }}
|
||||||
|
</p>
|
||||||
|
<component
|
||||||
|
:is="card ? Card : 'div'"
|
||||||
|
v-else
|
||||||
|
v-bind="card ? {padding: false, hasContent: false} : {}"
|
||||||
|
>
|
||||||
|
<div class="has-horizontal-overflow">
|
||||||
|
<table class="table has-actions is-hoverable is-fullwidth mbe-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-if="!hideLabelColumn">
|
||||||
|
{{ $t('task.attributes.project') }}
|
||||||
|
</th>
|
||||||
|
<th v-if="!hideLabelColumn">
|
||||||
|
{{ $t('timeTracking.form.task') }}
|
||||||
|
</th>
|
||||||
|
<th>{{ $t('task.comment.comment') }}</th>
|
||||||
|
<th class="nowrap">
|
||||||
|
{{ $t('timeTracking.list.time') }}
|
||||||
|
</th>
|
||||||
|
<th class="nowrap has-text-right">
|
||||||
|
{{ $t('timeTracking.list.duration') }}
|
||||||
|
</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="row.entry.id"
|
||||||
|
v-cy="'timeEntry'"
|
||||||
|
>
|
||||||
|
<td v-if="!hideLabelColumn">
|
||||||
|
<template
|
||||||
|
v-for="(project, i) in row.projectChain"
|
||||||
|
:key="project.id"
|
||||||
|
>
|
||||||
|
<RouterLink :to="{ name: 'project.index', params: { projectId: project.id } }">
|
||||||
|
{{ project.title }}
|
||||||
|
</RouterLink>
|
||||||
|
<span
|
||||||
|
v-if="i < row.projectChain.length - 1"
|
||||||
|
class="has-text-grey"
|
||||||
|
> > </span>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td v-if="!hideLabelColumn">
|
||||||
|
<RouterLink
|
||||||
|
v-if="row.entry.taskId > 0"
|
||||||
|
:to="{ name: 'task.detail', params: { id: row.entry.taskId } }"
|
||||||
|
>
|
||||||
|
{{ row.taskIdentifier }}{{ row.taskTitle ? ` - ${row.taskTitle}` : '' }}
|
||||||
|
</RouterLink>
|
||||||
|
</td>
|
||||||
|
<td class="has-text-grey">
|
||||||
|
{{ row.entry.comment }}
|
||||||
|
</td>
|
||||||
|
<td class="nowrap has-text-grey">
|
||||||
|
{{ timeRange(row.entry) }}
|
||||||
|
</td>
|
||||||
|
<td class="nowrap has-text-right has-text-weight-semibold">
|
||||||
|
{{ row.seconds === null ? '' : formatDuration(row.seconds) }}
|
||||||
|
</td>
|
||||||
|
<td class="nowrap has-text-right">
|
||||||
|
<template v-if="row.entry.userId === currentUserId">
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('menu.edit')"
|
||||||
|
v-cy="'editTimeEntry'"
|
||||||
|
class="entry-action"
|
||||||
|
:aria-label="$t('menu.edit')"
|
||||||
|
@click="emit('edit', row.entry)"
|
||||||
|
>
|
||||||
|
<Icon icon="pen" />
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('misc.delete')"
|
||||||
|
v-cy="'deleteTimeEntry'"
|
||||||
|
class="entry-action entry-delete"
|
||||||
|
:aria-label="$t('misc.delete')"
|
||||||
|
@click="emit('delete', row.entry.id)"
|
||||||
|
>
|
||||||
|
<Icon icon="trash-alt" />
|
||||||
|
</BaseButton>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
:colspan="hideLabelColumn ? 2 : 4"
|
||||||
|
class="has-text-weight-bold"
|
||||||
|
>
|
||||||
|
{{ $t('timeTracking.list.total') }}
|
||||||
|
</td>
|
||||||
|
<td class="nowrap has-text-right has-text-weight-bold">
|
||||||
|
{{ formatDuration(totalSeconds) }}
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref, computed, watch} from 'vue'
|
||||||
|
|
||||||
|
import Card from '@/components/misc/Card.vue'
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
|
import TaskService from '@/services/task'
|
||||||
|
import TaskModel from '@/models/task'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||||
|
import {formatDate} from '@/helpers/time/formatDate'
|
||||||
|
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||||
|
import {TIME_FORMAT} from '@/constants/timeFormat'
|
||||||
|
|
||||||
|
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||||
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
entries: ITimeEntry[]
|
||||||
|
// Drop the project + task columns when every entry belongs to the same task
|
||||||
|
// (e.g. the task-detail page).
|
||||||
|
hideLabelColumn?: boolean
|
||||||
|
// Wrap the table in a Card box; set false to render it inline (no card background).
|
||||||
|
card?: boolean
|
||||||
|
// Override the empty-state message (defaults to the per-day wording).
|
||||||
|
emptyText?: string
|
||||||
|
}>(), {
|
||||||
|
hideLabelColumn: false,
|
||||||
|
card: true,
|
||||||
|
emptyText: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
delete: [id: number]
|
||||||
|
edit: [entry: ITimeEntry]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
const {store: timeFormat} = useTimeFormat()
|
||||||
|
|
||||||
|
// Only the author can update/delete (enforced server-side); shared lists include
|
||||||
|
// others' entries, so hide the controls on rows the current user doesn't own.
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const currentUserId = computed(() => authStore.info?.id)
|
||||||
|
|
||||||
|
// Task entries carry only a task id; resolve the full task lazily (for its
|
||||||
|
// title, identifier, and parent project) and cache it.
|
||||||
|
const taskService = new TaskService()
|
||||||
|
const tasks = ref<Record<number, ITask>>({})
|
||||||
|
const inFlight = new Set<number>()
|
||||||
|
async function ensureTask(taskId: number) {
|
||||||
|
if (taskId === 0 || tasks.value[taskId] !== undefined || inFlight.has(taskId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inFlight.add(taskId)
|
||||||
|
try {
|
||||||
|
tasks.value[taskId] = await taskService.get(new TaskModel({id: taskId}))
|
||||||
|
} catch {
|
||||||
|
// Leave unresolved — the row falls back to #<id>.
|
||||||
|
} finally {
|
||||||
|
inFlight.delete(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.entries, entries => {
|
||||||
|
entries.forEach(entry => ensureTask(entry.taskId))
|
||||||
|
}, {immediate: true})
|
||||||
|
|
||||||
|
function entrySeconds(entry: ITimeEntry): number {
|
||||||
|
const end = entry.endTime ?? new Date()
|
||||||
|
return Math.floor((end.getTime() - entry.startTime.getTime()) / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = computed(() => props.entries.map(entry => {
|
||||||
|
const task = entry.taskId > 0 ? tasks.value[entry.taskId] : undefined
|
||||||
|
const projectId = task?.projectId ?? (entry.projectId > 0 ? entry.projectId : 0)
|
||||||
|
const project = projectId > 0 ? projectStore.projects[projectId] as IProject | undefined : undefined
|
||||||
|
const ancestors = project ? projectStore.getAncestors(project) : []
|
||||||
|
|
||||||
|
return {
|
||||||
|
entry,
|
||||||
|
// Full ancestor chain (root → leaf), each link-able.
|
||||||
|
projectChain: ancestors.map(p => ({id: p.id, title: getProjectTitle(p)})),
|
||||||
|
taskIdentifier: task ? (task.identifier || `#${task.index}`) : (entry.taskId > 0 ? `#${entry.taskId}` : ''),
|
||||||
|
taskTitle: task?.title ?? '',
|
||||||
|
// A running entry (no end) has no settled duration — leave it blank.
|
||||||
|
seconds: entry.endTime !== null ? entrySeconds(entry) : null,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const totalSeconds = computed(() => rows.value.reduce((sum, row) => sum + (row.seconds ?? 0), 0))
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return formatDate(date, timeFormat.value === TIME_FORMAT.HOURS_24 ? 'HH:mm' : 'hh:mm A')
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeRange(entry: ITimeEntry): string {
|
||||||
|
const start = formatTime(entry.startTime)
|
||||||
|
if (entry.endTime === null) {
|
||||||
|
return `${start} – …`
|
||||||
|
}
|
||||||
|
return `${start} – ${formatTime(entry.endTime)}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-action {
|
||||||
|
color: var(--grey-400);
|
||||||
|
transition: color $transition;
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
margin-inline-start: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-delete:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="timeTrackingStore.hasActiveTimer"
|
||||||
|
v-cy="'timerBadge'"
|
||||||
|
class="timer-badge"
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'time-tracking' }"
|
||||||
|
class="timer-badge__elapsed"
|
||||||
|
:title="$t('timeTracking.title')"
|
||||||
|
>
|
||||||
|
{{ elapsed }}
|
||||||
|
</RouterLink>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('timeTracking.stop')"
|
||||||
|
v-cy="'stopTimer'"
|
||||||
|
class="timer-badge__stop"
|
||||||
|
:aria-label="$t('timeTracking.stop')"
|
||||||
|
@click="stop"
|
||||||
|
>
|
||||||
|
<Icon icon="stop" />
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref, computed, onMounted, onUnmounted} from 'vue'
|
||||||
|
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||||
|
import {useConfigStore} from '@/stores/config'
|
||||||
|
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||||
|
|
||||||
|
const timeTrackingStore = useTimeTrackingStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
|
const now = ref(new Date())
|
||||||
|
let interval: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
const elapsed = computed(() => {
|
||||||
|
const timer = timeTrackingStore.activeTimer
|
||||||
|
if (timer === null) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const seconds = Math.max(0, Math.floor((now.value.getTime() - timer.startTime.getTime()) / 1000))
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const mmss = `${pad(Math.floor((seconds % 3600) / 60))}:${pad(seconds % 60)}`
|
||||||
|
return hours >= 1 ? `${hours}:${mmss}` : mmss
|
||||||
|
})
|
||||||
|
|
||||||
|
const isStopping = ref(false)
|
||||||
|
async function stop() {
|
||||||
|
if (isStopping.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isStopping.value = true
|
||||||
|
try {
|
||||||
|
await timeTrackingStore.stopTimer()
|
||||||
|
} finally {
|
||||||
|
isStopping.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// The badge lives in the always-mounted header, so it owns the app-wide timer
|
||||||
|
// sync. Subscribing is harmless when the feature is off (no events are emitted);
|
||||||
|
// only the hydrate hits the gated endpoint, so guard that.
|
||||||
|
timeTrackingStore.subscribeToTimerEvents()
|
||||||
|
if (configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING)) {
|
||||||
|
timeTrackingStore.hydrateActiveTimer()
|
||||||
|
}
|
||||||
|
interval = setInterval(() => {
|
||||||
|
now.value = new Date()
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
timeTrackingStore.unsubscribeFromTimerEvents()
|
||||||
|
if (interval !== undefined) {
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.timer-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-badge__elapsed {
|
||||||
|
padding-inline: .75rem .25rem;
|
||||||
|
color: var(--primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-badge__stop {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-inline: .5rem;
|
||||||
|
color: var(--grey-400);
|
||||||
|
transition: color $transition;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ref } from 'vue'
|
import { getCurrentInstance, ref } from 'vue'
|
||||||
import { createGlobalState, useIntervalFn } from '@vueuse/core'
|
import { createGlobalState, useIntervalFn } from '@vueuse/core'
|
||||||
import { onBeforeRouteUpdate } from 'vue-router'
|
import { onBeforeRouteUpdate } from 'vue-router'
|
||||||
|
|
||||||
|
|
@ -18,10 +18,14 @@ export const useGlobalNow = createGlobalState(() => {
|
||||||
|
|
||||||
useIntervalFn(update, GLOBAL_NOW_INTERVAL, { immediate: true })
|
useIntervalFn(update, GLOBAL_NOW_INTERVAL, { immediate: true })
|
||||||
|
|
||||||
// ensure the now value is refreshed when the route changes
|
// Now that this state can be initialised from a plain helper (formatDateSince), the
|
||||||
onBeforeRouteUpdate(() => {
|
// first caller is not guaranteed to be a component — guard the route hook accordingly.
|
||||||
update()
|
if (getCurrentInstance()) {
|
||||||
})
|
// ensure the now value is refreshed when the route changes
|
||||||
|
onBeforeRouteUpdate(() => {
|
||||||
|
update()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
now,
|
now,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import {describe, it, expect} from 'vitest'
|
||||||
|
import {buildStoredQuery} from './useTaskList'
|
||||||
|
|
||||||
|
describe('buildStoredQuery', () => {
|
||||||
|
it('includes sort when set', () => {
|
||||||
|
expect(buildStoredQuery({sort: 'due_date:asc', filter: undefined, s: undefined, page: 1}))
|
||||||
|
.toEqual({sort: 'due_date:asc'})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes filter and search when set', () => {
|
||||||
|
expect(buildStoredQuery({sort: undefined, filter: 'done = false', s: 'foo', page: 1}))
|
||||||
|
.toEqual({filter: 'done = false', s: 'foo'})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits page when it equals the default of 1', () => {
|
||||||
|
expect(buildStoredQuery({sort: 'id:desc', filter: undefined, s: undefined, page: 1}))
|
||||||
|
.toEqual({sort: 'id:desc'})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes page when greater than 1', () => {
|
||||||
|
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 3}))
|
||||||
|
.toEqual({page: '3'})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns an empty object when nothing is set', () => {
|
||||||
|
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 1}))
|
||||||
|
.toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips empty strings', () => {
|
||||||
|
expect(buildStoredQuery({sort: '', filter: '', s: '', page: 1}))
|
||||||
|
.toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
|
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
|
||||||
|
import {useRouter, isNavigationFailure} from 'vue-router'
|
||||||
|
import type {LocationQueryRaw} from 'vue-router'
|
||||||
import {useRouteQuery} from '@vueuse/router'
|
import {useRouteQuery} from '@vueuse/router'
|
||||||
|
|
||||||
import TaskCollectionService, {
|
import TaskCollectionService, {
|
||||||
|
|
@ -10,6 +12,7 @@ import type {ITask} from '@/modelTypes/ITask'
|
||||||
import {error} from '@/message'
|
import {error} from '@/message'
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
import {useViewFiltersStore} from '@/stores/viewFilters'
|
||||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||||
|
|
||||||
export type Order = 'asc' | 'desc' | 'none'
|
export type Order = 'asc' | 'desc' | 'none'
|
||||||
|
|
@ -59,6 +62,22 @@ const SORT_BY_DEFAULT: SortBy = {
|
||||||
id: 'desc',
|
id: 'desc',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TaskListQueryState {
|
||||||
|
sort: string | undefined
|
||||||
|
filter: string | undefined
|
||||||
|
s: string | undefined
|
||||||
|
page: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStoredQuery(state: TaskListQueryState): LocationQueryRaw {
|
||||||
|
const query: LocationQueryRaw = {}
|
||||||
|
if (state.sort) query.sort = state.sort
|
||||||
|
if (state.filter) query.filter = state.filter
|
||||||
|
if (state.s) query.s = state.s
|
||||||
|
if (state.page > 1) query.page = String(state.page)
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
// This makes sure an id sort order is always sorted last.
|
// This makes sure an id sort order is always sorted last.
|
||||||
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
|
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
|
||||||
// precedence over everything else, making any other sort columns pretty useless.
|
// precedence over everything else, making any other sort columns pretty useless.
|
||||||
|
|
@ -94,6 +113,9 @@ export function useTaskList(
|
||||||
const projectId = computed(() => projectIdGetter())
|
const projectId = computed(() => projectIdGetter())
|
||||||
const projectViewId = computed(() => projectViewIdGetter())
|
const projectViewId = computed(() => projectViewIdGetter())
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const viewFiltersStore = useViewFiltersStore()
|
||||||
|
|
||||||
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
|
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
|
||||||
|
|
||||||
const page = useRouteQuery('page', '1', { transform: Number })
|
const page = useRouteQuery('page', '1', { transform: Number })
|
||||||
|
|
@ -119,6 +141,55 @@ export function useTaskList(
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Mirror the URL query bits this composable owns into the store so
|
||||||
|
// in-project tab switches and sidebar re-visits can restore them.
|
||||||
|
//
|
||||||
|
// `ProjectList`/`ProjectTable` are reused across project switches (no
|
||||||
|
// `:key` on them in ProjectView.vue), so setup runs only once. We track
|
||||||
|
// the last viewId we synced — on every viewId transition, if the URL has
|
||||||
|
// none of our params and the store has an entry, restore it via
|
||||||
|
// `router.replace` and skip writing back the empty state we'd otherwise
|
||||||
|
// clobber the saved entry with.
|
||||||
|
let lastSyncedViewId: number | undefined
|
||||||
|
watch(
|
||||||
|
[projectViewId, sortQuery, filter, s, page],
|
||||||
|
([viewId, sortValue, filterValue, sValue, pageValue]) => {
|
||||||
|
const viewIdChanged = viewId !== lastSyncedViewId
|
||||||
|
lastSyncedViewId = viewId
|
||||||
|
|
||||||
|
// An invalid `?page=` becomes NaN via `transform: Number`; treat it as
|
||||||
|
// the default so it neither blocks restoration nor wipes stored state.
|
||||||
|
const currentPage = Number.isInteger(pageValue) ? pageValue : 1
|
||||||
|
const urlIsEmpty = !sortValue && !filterValue && !sValue && currentPage === 1
|
||||||
|
if (viewIdChanged && urlIsEmpty) {
|
||||||
|
const storedQuery = viewFiltersStore.getViewQuery(viewId)
|
||||||
|
if (Object.keys(storedQuery).length > 0) {
|
||||||
|
// Merge so unrelated query params on the route survive the restore.
|
||||||
|
// Swallow navigation failures (e.g. aborted/duplicated) so the
|
||||||
|
// ignored promise can't surface as an unhandled rejection.
|
||||||
|
router.replace({query: {...router.currentRoute.value.query, ...storedQuery}})
|
||||||
|
.catch(failure => {
|
||||||
|
if (!isNavigationFailure(failure)) throw failure
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = buildStoredQuery({
|
||||||
|
sort: sortValue as string | undefined,
|
||||||
|
filter: filterValue as string | undefined,
|
||||||
|
s: sValue as string | undefined,
|
||||||
|
page: currentPage,
|
||||||
|
})
|
||||||
|
if (Object.keys(query).length > 0) {
|
||||||
|
viewFiltersStore.setViewQuery(viewId, query)
|
||||||
|
} else {
|
||||||
|
viewFiltersStore.clearViewQuery(viewId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{immediate: true},
|
||||||
|
)
|
||||||
|
|
||||||
const allParams = computed(() => {
|
const allParams = computed(() => {
|
||||||
const loadParams = {...params.value}
|
const loadParams = {...params.value}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import {watch} from 'vue'
|
||||||
|
import {createSharedComposable, tryOnMounted} from '@vueuse/core'
|
||||||
|
import {storeToRefs} from 'pinia'
|
||||||
|
|
||||||
|
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||||
|
import {getFullBaseUrl} from '@/helpers/getFullBaseUrl'
|
||||||
|
|
||||||
|
const TRACKING_FAVICON = `${getFullBaseUrl()}images/icons/favicon-tracking-32x32.png`
|
||||||
|
|
||||||
|
function getFaviconLink(): HTMLLinkElement | null {
|
||||||
|
return document.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swaps in a favicon with a small red dot in the lower left corner while a timer
|
||||||
|
// is running, so an active time tracking session is visible even when the tab
|
||||||
|
// isn't focused.
|
||||||
|
export const useTimeTrackingFavicon = createSharedComposable(() => {
|
||||||
|
const {hasActiveTimer} = storeToRefs(useTimeTrackingStore())
|
||||||
|
|
||||||
|
const originalHref = getFaviconLink()?.getAttribute('href') ?? '/favicon.ico'
|
||||||
|
|
||||||
|
function update(active: boolean) {
|
||||||
|
const link = getFaviconLink()
|
||||||
|
if (link === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
link.href = active ? TRACKING_FAVICON : originalHref
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(hasActiveTimer, update, {flush: 'post'})
|
||||||
|
tryOnMounted(() => update(hasActiveTimer.value))
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
// Licensed "pro" features the server may advertise via /info's enabled_pro_features.
|
||||||
|
// Use these instead of bare strings when calling configStore.isProFeatureEnabled.
|
||||||
|
export const PRO_FEATURE = {
|
||||||
|
ADMIN_PANEL: 'admin_panel',
|
||||||
|
TIME_TRACKING: 'time_tracking',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ProFeature = typeof PRO_FEATURE[keyof typeof PRO_FEATURE]
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/**
|
||||||
|
* Hash-fragment prefix used to carry a post-login destination in the URL.
|
||||||
|
*
|
||||||
|
* Unlike the localStorage redirect, this lives in the address bar so the URL
|
||||||
|
* stays copyable between browsers (needed for native OAuth clients that open
|
||||||
|
* /oauth/authorize, see #2654). It uses the hash – not a query param – so the
|
||||||
|
* embedded OAuth parameters never reach server or proxy access logs.
|
||||||
|
*
|
||||||
|
* Must stay distinct from LINK_SHARE_HASH_PREFIX, which router.beforeEach
|
||||||
|
* special-cases.
|
||||||
|
*/
|
||||||
|
export const REDIRECT_HASH_PREFIX = '#redirect='
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
|
||||||
|
|
||||||
|
import {refreshToken, removeToken} from './auth'
|
||||||
|
|
||||||
|
// Count how many times the refresh endpoint is actually POSTed. The whole point
|
||||||
|
// of the in-flight dedup is that concurrent refreshToken() calls share a single
|
||||||
|
// underlying POST, independent of the Web Locks API.
|
||||||
|
let postCallCount = 0
|
||||||
|
let resolvePost: ((value: unknown) => void) | null = null
|
||||||
|
|
||||||
|
vi.mock('@/helpers/fetcher', () => ({
|
||||||
|
HTTPFactory: () => ({
|
||||||
|
post: vi.fn(() => {
|
||||||
|
postCallCount++
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolvePost = resolve
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/helpers/desktopAuth', () => ({
|
||||||
|
isDesktopApp: () => false,
|
||||||
|
refreshDesktopToken: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const FAKE_TOKEN = 'header.payload.signature'
|
||||||
|
|
||||||
|
function settlePost() {
|
||||||
|
resolvePost?.({data: {token: FAKE_TOKEN}})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('refreshToken in-flight dedup', () => {
|
||||||
|
const originalLocks = navigator.locks
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
postCallCount = 0
|
||||||
|
resolvePost = null
|
||||||
|
removeToken()
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(navigator, 'locks', {
|
||||||
|
value: originalLocks,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coalesces concurrent calls into a single POST when Web Locks is available', async () => {
|
||||||
|
// Stub a minimal Web Locks API: happy-dom leaves navigator.locks
|
||||||
|
// undefined, so without this the test would silently fall through to
|
||||||
|
// the insecure-HTTP branch and never exercise navigator.locks.request.
|
||||||
|
const requestSpy = vi.fn((_name: string, cb: () => unknown) => cb())
|
||||||
|
Object.defineProperty(navigator, 'locks', {
|
||||||
|
value: {request: requestSpy},
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const p1 = refreshToken(true)
|
||||||
|
const p2 = refreshToken(true)
|
||||||
|
|
||||||
|
// Both calls share one underlying request.
|
||||||
|
expect(postCallCount).toBe(1)
|
||||||
|
|
||||||
|
settlePost()
|
||||||
|
await Promise.all([p1, p2])
|
||||||
|
|
||||||
|
// The Web Locks branch actually ran...
|
||||||
|
expect(requestSpy).toHaveBeenCalledWith('vikunja-token-refresh', expect.any(Function))
|
||||||
|
// ...and the in-flight dedup still collapsed both calls into one POST.
|
||||||
|
expect(postCallCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coalesces concurrent calls into a single POST on insecure HTTP (no Web Locks)', async () => {
|
||||||
|
// Simulate an insecure HTTP context where navigator.locks is undefined.
|
||||||
|
Object.defineProperty(navigator, 'locks', {
|
||||||
|
value: undefined,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const p1 = refreshToken(true)
|
||||||
|
const p2 = refreshToken(true)
|
||||||
|
const p3 = refreshToken(true)
|
||||||
|
|
||||||
|
expect(postCallCount).toBe(1)
|
||||||
|
|
||||||
|
settlePost()
|
||||||
|
await Promise.all([p1, p2, p3])
|
||||||
|
|
||||||
|
expect(postCallCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows a fresh refresh after the previous one settled', async () => {
|
||||||
|
const p1 = refreshToken(true)
|
||||||
|
settlePost()
|
||||||
|
await p1
|
||||||
|
expect(postCallCount).toBe(1)
|
||||||
|
|
||||||
|
// The in-flight promise was reset, so a later refresh runs anew.
|
||||||
|
const p2 = refreshToken(true)
|
||||||
|
expect(postCallCount).toBe(2)
|
||||||
|
settlePost()
|
||||||
|
await p2
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not re-persist the token when logout happens during an in-flight refresh', async () => {
|
||||||
|
const p1 = refreshToken(true)
|
||||||
|
expect(postCallCount).toBe(1)
|
||||||
|
|
||||||
|
// User logs out while the refresh POST is still in flight.
|
||||||
|
removeToken()
|
||||||
|
|
||||||
|
// The in-flight POST resolves afterwards — it must not undo the logout.
|
||||||
|
settlePost()
|
||||||
|
await p1
|
||||||
|
|
||||||
|
expect(localStorage.getItem('token')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an older refresh settling does not clobber a newer in-flight one', async () => {
|
||||||
|
// Refresh A starts and stays in flight.
|
||||||
|
const pA = refreshToken(true)
|
||||||
|
expect(postCallCount).toBe(1)
|
||||||
|
const resolveA = resolvePost
|
||||||
|
|
||||||
|
// User logs out, which drops the in-flight reference to A.
|
||||||
|
removeToken()
|
||||||
|
|
||||||
|
// Refresh B starts; it must claim the in-flight slot.
|
||||||
|
const pB = refreshToken(true)
|
||||||
|
expect(postCallCount).toBe(2)
|
||||||
|
const resolveB = resolvePost
|
||||||
|
|
||||||
|
// A settles after B started. Its cleanup must NOT null the in-flight
|
||||||
|
// slot, since that slot now belongs to B. Without the `=== p` guard,
|
||||||
|
// A's .finally would clobber B and let a concurrent caller fire a
|
||||||
|
// second parallel POST.
|
||||||
|
resolveA?.({data: {token: FAKE_TOKEN}})
|
||||||
|
await pA
|
||||||
|
|
||||||
|
// A concurrent caller while B is still in flight must dedup to B —
|
||||||
|
// no third POST.
|
||||||
|
const pB2 = refreshToken(true)
|
||||||
|
expect(postCallCount).toBe(2)
|
||||||
|
|
||||||
|
resolveB?.({data: {token: FAKE_TOKEN}})
|
||||||
|
await Promise.all([pB, pB2])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -33,18 +33,53 @@ export const removeToken = () => {
|
||||||
savedToken = null
|
savedToken = null
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
localStorage.removeItem('desktopOAuthRefreshToken')
|
localStorage.removeItem('desktopOAuthRefreshToken')
|
||||||
|
|
||||||
|
// Bump the epoch and drop the in-flight refresh so a refresh that started
|
||||||
|
// before this logout can't re-persist a token after we cleared it.
|
||||||
|
authEpoch++
|
||||||
|
inFlightRefresh = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Coalesces concurrent same-tab refreshes into one POST. Web Locks (below) is
|
||||||
|
// secure-context-only, so on insecure HTTP there's no cross-tab coordination —
|
||||||
|
// without this guard, refreshes firing close together each spend the single-use
|
||||||
|
// cookie and all but one get a 401.
|
||||||
|
let inFlightRefresh: Promise<void> | null = null
|
||||||
|
|
||||||
|
// Incremented on every removeToken()/logout. A refresh captures the epoch when
|
||||||
|
// it starts and only persists its result if the epoch is unchanged, so a
|
||||||
|
// refresh that resolves after a logout can't undo it.
|
||||||
|
let authEpoch = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes an auth token while ensuring it is updated everywhere.
|
* Refreshes an auth token while ensuring it is updated everywhere.
|
||||||
* The refresh token is sent automatically as an HttpOnly cookie.
|
* The refresh token is sent automatically as an HttpOnly cookie.
|
||||||
* The server rotates the cookie on every call.
|
* The server rotates the cookie on every call.
|
||||||
*
|
*
|
||||||
* Uses the Web Locks API to coordinate across browser tabs. Only one tab
|
* Same-tab concurrent calls share one in-flight refresh (always-on dedup); the
|
||||||
* performs the actual refresh; other tabs waiting for the lock detect that
|
* Web Locks API inside adds cross-tab coordination only in secure contexts.
|
||||||
* the token in localStorage was already updated and adopt it directly.
|
|
||||||
*/
|
*/
|
||||||
export async function refreshToken(persist: boolean): Promise<void> {
|
export async function refreshToken(persist: boolean): Promise<void> {
|
||||||
|
if (inFlightRefresh) {
|
||||||
|
return inFlightRefresh
|
||||||
|
}
|
||||||
|
const p = doRefresh(persist)
|
||||||
|
inFlightRefresh = p
|
||||||
|
// Only clear if it still points to this promise — a logout (or a newer
|
||||||
|
// refresh started after it) may have replaced inFlightRefresh meanwhile.
|
||||||
|
p.finally(() => {
|
||||||
|
if (inFlightRefresh === p) {
|
||||||
|
inFlightRefresh = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRefresh(persist: boolean): Promise<void> {
|
||||||
|
// Snapshot the epoch so we can tell if a logout happened while we awaited.
|
||||||
|
const epochAtStart = authEpoch
|
||||||
|
const loggedOutSinceStart = () => authEpoch !== epochAtStart
|
||||||
|
|
||||||
// In desktop mode, refresh via IPC to the Electron main process
|
// In desktop mode, refresh via IPC to the Electron main process
|
||||||
if (isDesktopApp()) {
|
if (isDesktopApp()) {
|
||||||
const storedRefreshToken = localStorage.getItem('desktopOAuthRefreshToken')
|
const storedRefreshToken = localStorage.getItem('desktopOAuthRefreshToken')
|
||||||
|
|
@ -53,6 +88,9 @@ export async function refreshToken(persist: boolean): Promise<void> {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const tokens = await refreshDesktopToken(window.API_URL, storedRefreshToken)
|
const tokens = await refreshDesktopToken(window.API_URL, storedRefreshToken)
|
||||||
|
if (loggedOutSinceStart()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
saveToken(tokens.access_token, persist)
|
saveToken(tokens.access_token, persist)
|
||||||
localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token)
|
localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -65,7 +103,13 @@ export async function refreshToken(persist: boolean): Promise<void> {
|
||||||
// if another tab refreshed while we were queued.
|
// if another tab refreshed while we were queued.
|
||||||
const tokenBeforeLock = localStorage.getItem('token')
|
const tokenBeforeLock = localStorage.getItem('token')
|
||||||
|
|
||||||
const doRefresh = async () => {
|
const refreshUnderLock = async () => {
|
||||||
|
// A logout may have happened while we waited for the lock — don't
|
||||||
|
// re-adopt or re-fetch a token after the user signed out.
|
||||||
|
if (loggedOutSinceStart()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// If the token in localStorage changed while waiting for the lock,
|
// If the token in localStorage changed while waiting for the lock,
|
||||||
// another tab already refreshed. Just adopt the new token.
|
// another tab already refreshed. Just adopt the new token.
|
||||||
const currentToken = localStorage.getItem('token')
|
const currentToken = localStorage.getItem('token')
|
||||||
|
|
@ -78,6 +122,9 @@ export async function refreshToken(persist: boolean): Promise<void> {
|
||||||
const HTTP = HTTPFactory()
|
const HTTP = HTTPFactory()
|
||||||
try {
|
try {
|
||||||
const response = await HTTP.post('user/token/refresh')
|
const response = await HTTP.post('user/token/refresh')
|
||||||
|
if (loggedOutSinceStart()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
saveToken(response.data.token, persist)
|
saveToken(response.data.token, persist)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error('Error renewing token: ', {cause: e})
|
throw new Error('Error renewing token: ', {cause: e})
|
||||||
|
|
@ -85,10 +132,10 @@ export async function refreshToken(persist: boolean): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (navigator.locks) {
|
if (navigator.locks) {
|
||||||
await navigator.locks.request('vikunja-token-refresh', doRefresh)
|
await navigator.locks.request('vikunja-token-refresh', refreshUnderLock)
|
||||||
} else {
|
} else {
|
||||||
// Fallback for environments without Web Locks (e.g. insecure HTTP)
|
// Fallback for environments without Web Locks (e.g. insecure HTTP)
|
||||||
await doRefresh()
|
await refreshUnderLock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,17 @@ import {createRandomID} from '@/helpers/randomId'
|
||||||
import {computePosition, flip, shift, offset} from '@floating-ui/dom'
|
import {computePosition, flip, shift, offset} from '@floating-ui/dom'
|
||||||
import {nextTick} from 'vue'
|
import {nextTick} from 'vue'
|
||||||
import {eventToShortcutString} from '@/helpers/shortcut'
|
import {eventToShortcutString} from '@/helpers/shortcut'
|
||||||
|
import type {Editor} from '@tiptap/core'
|
||||||
|
import {getPopupContainer} from '@/components/input/editor/popupContainer'
|
||||||
|
|
||||||
export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Promise<string> {
|
export default function inputPrompt(pos: ClientRect, oldValue: string = '', editor?: Editor): Promise<string> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = 'link-input-' + createRandomID()
|
const id = 'link-input-' + createRandomID()
|
||||||
|
// Append inside the open task <dialog> (top-layer) when present, otherwise
|
||||||
|
// document.body. A body-level popup is painted behind a showModal() dialog
|
||||||
|
// and unfocusable through its focus trap, breaking the link prompt in the
|
||||||
|
// Kanban task popup (#2940).
|
||||||
|
const container = getPopupContainer(editor)
|
||||||
|
|
||||||
// Create popup element
|
// Create popup element
|
||||||
const popupElement = document.createElement('div')
|
const popupElement = document.createElement('div')
|
||||||
|
|
@ -26,7 +33,7 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
|
||||||
inputElement.value = oldValue
|
inputElement.value = oldValue
|
||||||
wrapperDiv.appendChild(inputElement)
|
wrapperDiv.appendChild(inputElement)
|
||||||
popupElement.appendChild(wrapperDiv)
|
popupElement.appendChild(wrapperDiv)
|
||||||
document.body.appendChild(popupElement)
|
container.appendChild(popupElement)
|
||||||
|
|
||||||
// Create a local mutable copy of the position for scroll tracking
|
// Create a local mutable copy of the position for scroll tracking
|
||||||
let currentRect = new DOMRect(pos.left, pos.top, pos.width, pos.height)
|
let currentRect = new DOMRect(pos.left, pos.top, pos.width, pos.height)
|
||||||
|
|
@ -82,15 +89,41 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
|
||||||
|
|
||||||
nextTick(() => document.getElementById(id)?.focus())
|
nextTick(() => document.getElementById(id)?.focus())
|
||||||
|
|
||||||
|
// The prompt is a sub-modal of the enclosing task <dialog>. Native modal
|
||||||
|
// dialogs close themselves on Escape ("cancel"); swallow that while the
|
||||||
|
// prompt is open so Escape only dismisses the prompt, not the task dialog.
|
||||||
|
const dialog = container.closest('dialog') as HTMLDialogElement | null
|
||||||
|
const handleDialogCancel = (event: Event) => event.preventDefault()
|
||||||
|
dialog?.addEventListener('cancel', handleDialogCancel)
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (!popupElement.contains(event.target as Node)) {
|
||||||
|
resolve('')
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
window.removeEventListener('scroll', handleScroll, true)
|
window.removeEventListener('scroll', handleScroll, true)
|
||||||
if (document.body.contains(popupElement)) {
|
document.removeEventListener('click', handleClickOutside)
|
||||||
document.body.removeChild(popupElement)
|
dialog?.removeEventListener('cancel', handleDialogCancel)
|
||||||
|
if (container.contains(popupElement)) {
|
||||||
|
container.removeChild(popupElement)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById(id)?.addEventListener('keydown', event => {
|
document.getElementById(id)?.addEventListener('keydown', event => {
|
||||||
const shortcutString = eventToShortcutString(event)
|
const shortcutString = eventToShortcutString(event)
|
||||||
|
|
||||||
|
if (shortcutString === 'Escape') {
|
||||||
|
// Stop the native <dialog> from closing on Escape; cancel the prompt only.
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
resolve('')
|
||||||
|
cleanup()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (shortcutString !== 'Enter') {
|
if (shortcutString !== 'Enter') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -105,15 +138,6 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
|
||||||
cleanup()
|
cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Close on click outside
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (!popupElement.contains(event.target as Node)) {
|
|
||||||
resolve('')
|
|
||||||
cleanup()
|
|
||||||
document.removeEventListener('click', handleClickOutside)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add slight delay to prevent immediate closing
|
// Add slight delay to prevent immediate closing
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {i18n} from '@/i18n'
|
||||||
import {createSharedComposable} from '@vueuse/core'
|
import {createSharedComposable} from '@vueuse/core'
|
||||||
import {computed, toValue, type MaybeRefOrGetter} from 'vue'
|
import {computed, toValue, type MaybeRefOrGetter} from 'vue'
|
||||||
import {useDateDisplay} from '@/composables/useDateDisplay'
|
import {useDateDisplay} from '@/composables/useDateDisplay'
|
||||||
|
import {useGlobalNow} from '@/composables/useGlobalNow'
|
||||||
import {useTimeFormat} from '@/composables/useTimeFormat'
|
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||||
import {DATE_DISPLAY, type DateDisplay} from '@/constants/dateDisplay'
|
import {DATE_DISPLAY, type DateDisplay} from '@/constants/dateDisplay'
|
||||||
import {TIME_FORMAT, type TimeFormat} from '@/constants/timeFormat'
|
import {TIME_FORMAT, type TimeFormat} from '@/constants/timeFormat'
|
||||||
|
|
@ -49,8 +50,13 @@ export const formatDateSince = (date: Date | string | null) => {
|
||||||
|
|
||||||
const locale = DAYJS_LOCALE_MAPPING[i18n.global.locale.value.toLowerCase()] ?? 'en'
|
const locale = DAYJS_LOCALE_MAPPING[i18n.global.locale.value.toLowerCase()] ?? 'en'
|
||||||
|
|
||||||
|
// Computing the relative string against the shared, ticking `now` (instead of fromNow's
|
||||||
|
// internal Date.now()) makes every reactive caller re-render on the 60s tick, so open views
|
||||||
|
// don't keep showing a stale "x minutes ago".
|
||||||
|
const {now} = useGlobalNow()
|
||||||
|
|
||||||
return date
|
return date
|
||||||
? dayjs(date).locale(locale).fromNow()
|
? dayjs(date).locale(locale).from(now.value)
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import {describe, it, expect} from 'vitest'
|
||||||
|
|
||||||
|
import {smartFillStart} from './smartFillStart'
|
||||||
|
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||||
|
|
||||||
|
function entry(startTime: Date, endTime: Date | null): ITimeEntry {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
taskId: 0,
|
||||||
|
projectId: 0,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
comment: '',
|
||||||
|
created: startTime,
|
||||||
|
updated: startTime,
|
||||||
|
maxPermission: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('smartFillStart', () => {
|
||||||
|
const now = new Date('2026-06-07T15:30:00')
|
||||||
|
|
||||||
|
it('continues from the latest entry end time', () => {
|
||||||
|
const entries = [
|
||||||
|
entry(new Date('2026-06-07T09:00:00'), new Date('2026-06-07T10:00:00')),
|
||||||
|
entry(new Date('2026-06-07T11:00:00'), new Date('2026-06-07T12:30:00')),
|
||||||
|
]
|
||||||
|
expect(smartFillStart(entries, '09:00', now)).toEqual(new Date('2026-06-07T12:30:00'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores still-running entries (no end) when picking the latest end', () => {
|
||||||
|
const entries = [
|
||||||
|
entry(new Date('2026-06-07T09:00:00'), new Date('2026-06-07T10:00:00')),
|
||||||
|
entry(new Date('2026-06-07T13:00:00'), null),
|
||||||
|
]
|
||||||
|
expect(smartFillStart(entries, '09:00', now)).toEqual(new Date('2026-06-07T10:00:00'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the default start time on the current day when there are no entries', () => {
|
||||||
|
expect(smartFillStart([], '08:15', now)).toEqual(new Date('2026-06-07T08:15:00'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to 09:00 when no default is configured', () => {
|
||||||
|
expect(smartFillStart([], '', now)).toEqual(new Date('2026-06-07T09:00:00'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caps the default start at now when it would be in the future (before 09:00)', () => {
|
||||||
|
const beforeNine = new Date('2026-06-07T07:30:00')
|
||||||
|
expect(smartFillStart([], '09:00', beforeNine)).toEqual(beforeNine)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caps a future last-entry end at now', () => {
|
||||||
|
const entries = [entry(new Date('2026-06-07T16:00:00'), new Date('2026-06-07T17:00:00'))]
|
||||||
|
expect(smartFillStart(entries, '09:00', now)).toEqual(now)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||||
|
|
||||||
|
// The smart-clock start time: continue from the most recent entry's end so
|
||||||
|
// consecutive entries don't overlap or leave gaps; with no completed entry to
|
||||||
|
// continue from, fall back to the user's configured default start (HH:MM) on
|
||||||
|
// the given day.
|
||||||
|
export function smartFillStart(recentEntries: ITimeEntry[], defaultStart: string, now: Date): Date {
|
||||||
|
// The filled range ends at now, so a start after now would be inverted (and
|
||||||
|
// rejected on save). Cap at now — e.g. the 09:00 fallback before 9am.
|
||||||
|
const cap = (start: Date) => (start.getTime() > now.getTime() ? new Date(now) : start)
|
||||||
|
|
||||||
|
const lastEnd = recentEntries
|
||||||
|
.map(entry => entry.endTime)
|
||||||
|
.filter((end): end is Date => end !== null)
|
||||||
|
.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||||
|
if (lastEnd !== undefined) {
|
||||||
|
return cap(new Date(lastEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
const [hours, minutes] = (defaultStart || '09:00').split(':').map(Number)
|
||||||
|
const start = new Date(now)
|
||||||
|
start.setHours(hours || 0, minutes || 0, 0, 0)
|
||||||
|
return cap(start)
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ export const SUPPORTED_LOCALES = {
|
||||||
'ja-JP': '日本語',
|
'ja-JP': '日本語',
|
||||||
'hu-HU': 'Magyar',
|
'hu-HU': 'Magyar',
|
||||||
'ar-SA': 'اَلْعَرَبِيَّةُ',
|
'ar-SA': 'اَلْعَرَبِيَّةُ',
|
||||||
|
'fa-IR': 'فارسی',
|
||||||
'sl-SI': 'Slovenščina',
|
'sl-SI': 'Slovenščina',
|
||||||
'pt-BR': 'Português Brasileiro',
|
'pt-BR': 'Português Brasileiro',
|
||||||
'hr-HR': 'Hrvatski',
|
'hr-HR': 'Hrvatski',
|
||||||
|
|
@ -52,7 +53,7 @@ export const DEFAULT_LANGUAGE: SupportedLocale= 'en'
|
||||||
|
|
||||||
export type ISOLanguage = string
|
export type ISOLanguage = string
|
||||||
|
|
||||||
const RTL_LANGUAGES = ['ar-SA', 'he-IL'] as const
|
const RTL_LANGUAGES = ['ar-SA', 'he-IL', 'fa-IR'] as const
|
||||||
|
|
||||||
export function isRTLLanguage(locale: SupportedLocale): boolean {
|
export function isRTLLanguage(locale: SupportedLocale): boolean {
|
||||||
return RTL_LANGUAGES.includes(locale as typeof RTL_LANGUAGES[number])
|
return RTL_LANGUAGES.includes(locale as typeof RTL_LANGUAGES[number])
|
||||||
|
|
|
||||||
|
|
@ -284,8 +284,7 @@
|
||||||
"default": "افتراضي",
|
"default": "افتراضي",
|
||||||
"month": "شهر",
|
"month": "شهر",
|
||||||
"day": "يوم",
|
"day": "يوم",
|
||||||
"hour": "ساعة",
|
"hour": "ساعة"
|
||||||
"range": "نطاق التاريخ"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "جدول",
|
"title": "جدول",
|
||||||
|
|
@ -294,7 +293,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "الحد: {limit}",
|
"limit": "الحد: {limit}",
|
||||||
"noLimit": "غير محدد",
|
|
||||||
"doneBucket": "حافظة المهام المكتملة",
|
"doneBucket": "حافظة المهام المكتملة",
|
||||||
"doneBucketHint": "سيتم تلقائياً وضع علامة مكتمل على جميع المهام التي تم نقلها إلى هذه الحافظة.",
|
"doneBucketHint": "سيتم تلقائياً وضع علامة مكتمل على جميع المهام التي تم نقلها إلى هذه الحافظة.",
|
||||||
"doneBucketHintExtended": "سيتم وضع علامة مكتمل على جميع المهام التي تم نقلها إلى حافظة المهام المكتملة. كما سيتم نقل جميع المهام المكتملة من أماكن أخرى.",
|
"doneBucketHintExtended": "سيتم وضع علامة مكتمل على جميع المهام التي تم نقلها إلى حافظة المهام المكتملة. كما سيتم نقل جميع المهام المكتملة من أماكن أخرى.",
|
||||||
|
|
|
||||||
|
|
@ -314,8 +314,7 @@
|
||||||
"default": "По подразбиране",
|
"default": "По подразбиране",
|
||||||
"month": "Месец",
|
"month": "Месец",
|
||||||
"day": "Ден",
|
"day": "Ден",
|
||||||
"hour": "Час",
|
"hour": "Час"
|
||||||
"range": "Времеви диапазон"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Таблица",
|
"title": "Таблица",
|
||||||
|
|
@ -324,7 +323,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Канбан",
|
"title": "Канбан",
|
||||||
"limit": "Лимит: {limit}",
|
"limit": "Лимит: {limit}",
|
||||||
"noLimit": "Не е зададен",
|
|
||||||
"doneBucket": "Колона за завършени",
|
"doneBucket": "Колона за завършени",
|
||||||
"doneBucketHint": "Всички задачи, преместени в тази колона, автоматично ще бъдат маркирани като завършени.",
|
"doneBucketHint": "Всички задачи, преместени в тази колона, автоматично ще бъдат маркирани като завършени.",
|
||||||
"doneBucketHintExtended": "Всички задачи, преместени в колоната за завършени, ще бъдат автоматично маркирани като завършени. Всички задачи, маркирани като завършени от другаде, също ще бъдат преместени тук.",
|
"doneBucketHintExtended": "Всички задачи, преместени в колоната за завършени, ще бъдат автоматично маркирани като завършени. Всички задачи, маркирани като завършени от другаде, също ще бъдат преместени тук.",
|
||||||
|
|
|
||||||
|
|
@ -383,7 +383,6 @@
|
||||||
"month": "Měsíc",
|
"month": "Měsíc",
|
||||||
"day": "Den",
|
"day": "Den",
|
||||||
"hour": "Hodina",
|
"hour": "Hodina",
|
||||||
"range": "Časové období",
|
|
||||||
"chartLabel": "Projektový Ganttův diagram",
|
"chartLabel": "Projektový Ganttův diagram",
|
||||||
"taskBarsForRow": "Chlívky pro řádek {rowId}",
|
"taskBarsForRow": "Chlívky pro řádek {rowId}",
|
||||||
"taskBarLabel": "Úkol: {task}. Od {startDate} do {endDate}. {dateType}. Klikněte pro úpravu, přetáhněte pro přesun.",
|
"taskBarLabel": "Úkol: {task}. Od {startDate} do {endDate}. {dateType}. Klikněte pro úpravu, přetáhněte pro přesun.",
|
||||||
|
|
@ -412,7 +411,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Limit: {limit}",
|
"limit": "Limit: {limit}",
|
||||||
"noLimit": "Nenastaveno",
|
|
||||||
"doneBucket": "Sloupec \"Hotovo\"",
|
"doneBucket": "Sloupec \"Hotovo\"",
|
||||||
"doneBucketHint": "Všechny úkoly přesunuté do tohoto sloupce budou automaticky označeny jako dokončené.",
|
"doneBucketHint": "Všechny úkoly přesunuté do tohoto sloupce budou automaticky označeny jako dokončené.",
|
||||||
"doneBucketHintExtended": "Všechny úkoly přesunuté do sloupce \"Hotovo\" budou označeny jako dokončené automaticky. Všechny úkoly označené jako dokončené jinde sem budou přesunuty také.",
|
"doneBucketHintExtended": "Všechny úkoly přesunuté do sloupce \"Hotovo\" budou označeny jako dokončené automaticky. Všechny úkoly označené jako dokončené jinde sem budou přesunuty také.",
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@
|
||||||
"yyyy/mm/dd": "JJJJ/MM/TT"
|
"yyyy/mm/dd": "JJJJ/MM/TT"
|
||||||
},
|
},
|
||||||
"timeFormat": "Zeitformat",
|
"timeFormat": "Zeitformat",
|
||||||
|
"timeTrackingDefaultStart": "Startzeit für die Zeiterfassung",
|
||||||
"timeFormatOptions": {
|
"timeFormatOptions": {
|
||||||
"12h": "12 Stunden (AM/PM)",
|
"12h": "12 Stunden (AM/PM)",
|
||||||
"24h": "24 Stunden (HH:mm)"
|
"24h": "24 Stunden (HH:mm)"
|
||||||
|
|
@ -392,6 +393,7 @@
|
||||||
"title": "Dupliziere dieses Projekt",
|
"title": "Dupliziere dieses Projekt",
|
||||||
"label": "Duplizieren",
|
"label": "Duplizieren",
|
||||||
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
|
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
|
||||||
|
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
|
||||||
"success": "Das Projekt wurde erfolgreich dupliziert."
|
"success": "Das Projekt wurde erfolgreich dupliziert."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
|
|
@ -470,7 +472,6 @@
|
||||||
"month": "Monat",
|
"month": "Monat",
|
||||||
"day": "Tag",
|
"day": "Tag",
|
||||||
"hour": "Stunde",
|
"hour": "Stunde",
|
||||||
"range": "Zeitraum",
|
|
||||||
"chartLabel": "Projekt Gantt-Diagramm",
|
"chartLabel": "Projekt Gantt-Diagramm",
|
||||||
"taskBarsForRow": "Aufgabenleisten für Zeile {rowId}",
|
"taskBarsForRow": "Aufgabenleisten für Zeile {rowId}",
|
||||||
"taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.",
|
"taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.",
|
||||||
|
|
@ -499,7 +500,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Limit: {limit}",
|
"limit": "Limit: {limit}",
|
||||||
"noLimit": "Nicht gesetzt",
|
|
||||||
"doneBucket": "Erledigt Spalte",
|
"doneBucket": "Erledigt Spalte",
|
||||||
"doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.",
|
"doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.",
|
||||||
"doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.",
|
"doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.",
|
||||||
|
|
@ -783,7 +783,10 @@
|
||||||
"closeDialog": "Dialog schließen",
|
"closeDialog": "Dialog schließen",
|
||||||
"closeQuickActions": "Schnellaktionen schließen",
|
"closeQuickActions": "Schnellaktionen schließen",
|
||||||
"skipToContent": "Überspringen und zum Hauptinhalt gehen",
|
"skipToContent": "Überspringen und zum Hauptinhalt gehen",
|
||||||
"sortBy": "Sortieren nach"
|
"sortBy": "Sortieren nach",
|
||||||
|
"dateRange": "Zeitraum",
|
||||||
|
"notSet": "Nicht festgelegt",
|
||||||
|
"user": "Benutzer:in"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"projectColor": "Projektfarbe",
|
"projectColor": "Projektfarbe",
|
||||||
|
|
@ -993,6 +996,7 @@
|
||||||
"repeatAfter": "Wiederholung setzen",
|
"repeatAfter": "Wiederholung setzen",
|
||||||
"percentDone": "Fortschritt einstellen",
|
"percentDone": "Fortschritt einstellen",
|
||||||
"attachments": "Anhänge hinzufügen",
|
"attachments": "Anhänge hinzufügen",
|
||||||
|
"timeTracking": "Zeit erfassen",
|
||||||
"relatedTasks": "Beziehung hinzufügen",
|
"relatedTasks": "Beziehung hinzufügen",
|
||||||
"moveProject": "Verschieben",
|
"moveProject": "Verschieben",
|
||||||
"duplicate": "Duplizieren",
|
"duplicate": "Duplizieren",
|
||||||
|
|
@ -1462,6 +1466,32 @@
|
||||||
"frontendVersion": "Frontend-Version: {version}",
|
"frontendVersion": "Frontend-Version: {version}",
|
||||||
"apiVersion": "API-Version: {version}"
|
"apiVersion": "API-Version: {version}"
|
||||||
},
|
},
|
||||||
|
"timeTracking": {
|
||||||
|
"title": "Zeiterfassung",
|
||||||
|
"stop": "Timer stoppen",
|
||||||
|
"logTime": "Zeit buchen",
|
||||||
|
"editEntry": "Eintrag bearbeiten",
|
||||||
|
"form": {
|
||||||
|
"task": "Aufgabe",
|
||||||
|
"taskSearch": "Nach einer Aufgabe suchen…",
|
||||||
|
"commentPlaceholder": "Woran hast du gearbeitet?",
|
||||||
|
"save": "Speichern",
|
||||||
|
"startTimer": "Timer starten",
|
||||||
|
"update": "Eintrag aktualisieren",
|
||||||
|
"smartFill": "Vom letzten Eintrag ausfüllen"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"emptyTask": "Noch keine Zeit für diese Aufgabe getrackt.",
|
||||||
|
"emptyFiltered": "Keine Zeiterfassung innerhalb der ausgewählten Filter.",
|
||||||
|
"total": "Gesamt",
|
||||||
|
"time": "Uhrzeit",
|
||||||
|
"duration": "Dauer"
|
||||||
|
},
|
||||||
|
"browse": {
|
||||||
|
"selectRange": "Bereich wählen",
|
||||||
|
"userSearch": "Nach einer:m Benutzer:in suchen…"
|
||||||
|
}
|
||||||
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"units": {
|
"units": {
|
||||||
"seconds": "Sekunde|Sekunden",
|
"seconds": "Sekunde|Sekunden",
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@
|
||||||
"yyyy/mm/dd": "JJJJ/MM/TT"
|
"yyyy/mm/dd": "JJJJ/MM/TT"
|
||||||
},
|
},
|
||||||
"timeFormat": "Zeitformat",
|
"timeFormat": "Zeitformat",
|
||||||
|
"timeTrackingDefaultStart": "Startzeit für die Zeiterfassung",
|
||||||
"timeFormatOptions": {
|
"timeFormatOptions": {
|
||||||
"12h": "12 Stunden (AM/PM)",
|
"12h": "12 Stunden (AM/PM)",
|
||||||
"24h": "24 Stunden (HH:mm)"
|
"24h": "24 Stunden (HH:mm)"
|
||||||
|
|
@ -392,6 +393,7 @@
|
||||||
"title": "Dupliziere dieses Projekt",
|
"title": "Dupliziere dieses Projekt",
|
||||||
"label": "Duplizieren",
|
"label": "Duplizieren",
|
||||||
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
|
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
|
||||||
|
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
|
||||||
"success": "Das Projekt wurde erfolgreich dupliziert."
|
"success": "Das Projekt wurde erfolgreich dupliziert."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
|
|
@ -470,7 +472,6 @@
|
||||||
"month": "Monat",
|
"month": "Monat",
|
||||||
"day": "Tag",
|
"day": "Tag",
|
||||||
"hour": "Stunde",
|
"hour": "Stunde",
|
||||||
"range": "Zeitraum",
|
|
||||||
"chartLabel": "Projekt Gantt-Diagramm",
|
"chartLabel": "Projekt Gantt-Diagramm",
|
||||||
"taskBarsForRow": "Aufgabenleisten für Zeile {rowId}",
|
"taskBarsForRow": "Aufgabenleisten für Zeile {rowId}",
|
||||||
"taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.",
|
"taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.",
|
||||||
|
|
@ -499,7 +500,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Limit: {limit}",
|
"limit": "Limit: {limit}",
|
||||||
"noLimit": "Nicht gesetzt",
|
|
||||||
"doneBucket": "Erledigt Spalte",
|
"doneBucket": "Erledigt Spalte",
|
||||||
"doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.",
|
"doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.",
|
||||||
"doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.",
|
"doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.",
|
||||||
|
|
@ -783,7 +783,10 @@
|
||||||
"closeDialog": "Dialog schließen",
|
"closeDialog": "Dialog schließen",
|
||||||
"closeQuickActions": "Schnellaktionen schließen",
|
"closeQuickActions": "Schnellaktionen schließen",
|
||||||
"skipToContent": "Überspringen und zum Hauptinhalt gehen",
|
"skipToContent": "Überspringen und zum Hauptinhalt gehen",
|
||||||
"sortBy": "Sortieren nach"
|
"sortBy": "Sortieren nach",
|
||||||
|
"dateRange": "Zeitraum",
|
||||||
|
"notSet": "Nicht festgelegt",
|
||||||
|
"user": "Benutzer:in"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"projectColor": "Projektfarbe",
|
"projectColor": "Projektfarbe",
|
||||||
|
|
@ -993,6 +996,7 @@
|
||||||
"repeatAfter": "Wiederholung setzen",
|
"repeatAfter": "Wiederholung setzen",
|
||||||
"percentDone": "Fortschritt einstellen",
|
"percentDone": "Fortschritt einstellen",
|
||||||
"attachments": "Anhänge hinzufügen",
|
"attachments": "Anhänge hinzufügen",
|
||||||
|
"timeTracking": "Zeit erfassen",
|
||||||
"relatedTasks": "Beziehung hinzufügen",
|
"relatedTasks": "Beziehung hinzufügen",
|
||||||
"moveProject": "Verschieben",
|
"moveProject": "Verschieben",
|
||||||
"duplicate": "Duplizieren",
|
"duplicate": "Duplizieren",
|
||||||
|
|
@ -1462,6 +1466,32 @@
|
||||||
"frontendVersion": "Frontend-Version: {version}",
|
"frontendVersion": "Frontend-Version: {version}",
|
||||||
"apiVersion": "API-Version: {version}"
|
"apiVersion": "API-Version: {version}"
|
||||||
},
|
},
|
||||||
|
"timeTracking": {
|
||||||
|
"title": "Zeiterfassung",
|
||||||
|
"stop": "Timer stoppen",
|
||||||
|
"logTime": "Zeit buchen",
|
||||||
|
"editEntry": "Eintrag bearbeiten",
|
||||||
|
"form": {
|
||||||
|
"task": "Aufgabe",
|
||||||
|
"taskSearch": "Nach einer Aufgabe suchen…",
|
||||||
|
"commentPlaceholder": "Woran hast du gearbeitet?",
|
||||||
|
"save": "Speichern",
|
||||||
|
"startTimer": "Timer starten",
|
||||||
|
"update": "Eintrag aktualisieren",
|
||||||
|
"smartFill": "Vom letzten Eintrag ausfüllen"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"emptyTask": "Noch keine Zeit für diese Aufgabe getrackt.",
|
||||||
|
"emptyFiltered": "Keine Zeiterfassung innerhalb der ausgewählten Filter.",
|
||||||
|
"total": "Gesamt",
|
||||||
|
"time": "Uhrzeit",
|
||||||
|
"duration": "Dauer"
|
||||||
|
},
|
||||||
|
"browse": {
|
||||||
|
"selectRange": "Bereich wählen",
|
||||||
|
"userSearch": "Nach einer:m Benutzer:in suchen…"
|
||||||
|
}
|
||||||
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"units": {
|
"units": {
|
||||||
"seconds": "Sekunde|Sekunden",
|
"seconds": "Sekunde|Sekunden",
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@
|
||||||
"yyyy/mm/dd": "ΕΕΕΕ/ΜΜ/ΗΗ"
|
"yyyy/mm/dd": "ΕΕΕΕ/ΜΜ/ΗΗ"
|
||||||
},
|
},
|
||||||
"timeFormat": "Μορφή ώρας",
|
"timeFormat": "Μορφή ώρας",
|
||||||
|
"timeTrackingDefaultStart": "Ώρα έναρξης παρακολούθησης χρόνου με έξυπνο-γέμισμα",
|
||||||
"timeFormatOptions": {
|
"timeFormatOptions": {
|
||||||
"12h": "12 ώρες (ΠΜ/ΜΜ)",
|
"12h": "12 ώρες (ΠΜ/ΜΜ)",
|
||||||
"24h": "24 ώρες (ΩΩ:ΛΛ)"
|
"24h": "24 ώρες (ΩΩ:ΛΛ)"
|
||||||
|
|
@ -470,7 +471,6 @@
|
||||||
"month": "Μήνας",
|
"month": "Μήνας",
|
||||||
"day": "Ημέρα",
|
"day": "Ημέρα",
|
||||||
"hour": "Ώρα",
|
"hour": "Ώρα",
|
||||||
"range": "Εύρος Ημερομηνιών",
|
|
||||||
"chartLabel": "Γράφημα Gantt Έργου",
|
"chartLabel": "Γράφημα Gantt Έργου",
|
||||||
"taskBarsForRow": "Μπάρες εργασίας για τη γραμμή {rowId}",
|
"taskBarsForRow": "Μπάρες εργασίας για τη γραμμή {rowId}",
|
||||||
"taskBarLabel": "Εργασία: {task}. Από {startDate} έως {endDate}. {dateType}. Κάντε κλικ για επεξεργασία, σύρετε για μετακίνηση.",
|
"taskBarLabel": "Εργασία: {task}. Από {startDate} έως {endDate}. {dateType}. Κάντε κλικ για επεξεργασία, σύρετε για μετακίνηση.",
|
||||||
|
|
@ -499,7 +499,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Όριο: {limit}",
|
"limit": "Όριο: {limit}",
|
||||||
"noLimit": "Δεν έχει οριστεί",
|
|
||||||
"doneBucket": "Κάδος για ολοκληρωμένα",
|
"doneBucket": "Κάδος για ολοκληρωμένα",
|
||||||
"doneBucketHint": "Όλες οι εργασίες που μετακινούνται σε αυτόν τον κάδο θα σημειώνονται αυτόματα ως ολοκληρωμένες.",
|
"doneBucketHint": "Όλες οι εργασίες που μετακινούνται σε αυτόν τον κάδο θα σημειώνονται αυτόματα ως ολοκληρωμένες.",
|
||||||
"doneBucketHintExtended": "Όλες οι εργασίες που μετακινούνται στον κάδο των ολοκληρωμένων θα σημειώνονται αυτόματα ως ολοκληρωμένες. Όλες οι εργασίες που σημειώνονται ως ολοκληρωμένες από αλλού επίσης θα μετακινηθούν.",
|
"doneBucketHintExtended": "Όλες οι εργασίες που μετακινούνται στον κάδο των ολοκληρωμένων θα σημειώνονται αυτόματα ως ολοκληρωμένες. Όλες οι εργασίες που σημειώνονται ως ολοκληρωμένες από αλλού επίσης θα μετακινηθούν.",
|
||||||
|
|
@ -783,7 +782,10 @@
|
||||||
"closeDialog": "Κλείσμο του διαλόγου",
|
"closeDialog": "Κλείσμο του διαλόγου",
|
||||||
"closeQuickActions": "Κλείσιμο των γρήγορων ενεργειών",
|
"closeQuickActions": "Κλείσιμο των γρήγορων ενεργειών",
|
||||||
"skipToContent": "Μετάβαση στο κύριο περιεχόμενο",
|
"skipToContent": "Μετάβαση στο κύριο περιεχόμενο",
|
||||||
"sortBy": "Ταξινόμηση ανά"
|
"sortBy": "Ταξινόμηση ανά",
|
||||||
|
"dateRange": "Εύρος ημερομηνιών",
|
||||||
|
"notSet": "Μη ορισμένο",
|
||||||
|
"user": "Χρήστης"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"projectColor": "Χρώμα έργου",
|
"projectColor": "Χρώμα έργου",
|
||||||
|
|
@ -993,6 +995,7 @@
|
||||||
"repeatAfter": "Ορισμός Επαναλαμβανόμενου Διαστήματος",
|
"repeatAfter": "Ορισμός Επαναλαμβανόμενου Διαστήματος",
|
||||||
"percentDone": "Ορισμός Προόδου",
|
"percentDone": "Ορισμός Προόδου",
|
||||||
"attachments": "Προσθήκη Συνημμένων",
|
"attachments": "Προσθήκη Συνημμένων",
|
||||||
|
"timeTracking": "Χρόνος ίχνους",
|
||||||
"relatedTasks": "Προσθήκη Συσχέτισης",
|
"relatedTasks": "Προσθήκη Συσχέτισης",
|
||||||
"moveProject": "Μετακίνηση",
|
"moveProject": "Μετακίνηση",
|
||||||
"duplicate": "Αντιγραφή",
|
"duplicate": "Αντιγραφή",
|
||||||
|
|
@ -1462,6 +1465,32 @@
|
||||||
"frontendVersion": "Έκδοση frontend: {version}",
|
"frontendVersion": "Έκδοση frontend: {version}",
|
||||||
"apiVersion": "Έκδοση API: {version}"
|
"apiVersion": "Έκδοση API: {version}"
|
||||||
},
|
},
|
||||||
|
"timeTracking": {
|
||||||
|
"title": "Ιχνηλάτηση χρόνου",
|
||||||
|
"stop": "Διακοπή χρονομέτρου",
|
||||||
|
"logTime": "Καταγραφή χρόνου",
|
||||||
|
"editEntry": "Επεξεργασία εγγραφής",
|
||||||
|
"form": {
|
||||||
|
"task": "Εργασία",
|
||||||
|
"taskSearch": "Αναζήτηση για μια εργασία…",
|
||||||
|
"commentPlaceholder": "Σε τι δουλέψατε;",
|
||||||
|
"save": "Αποθήκευση εγγραφής",
|
||||||
|
"startTimer": "Έναρξη χρονοµέτρου",
|
||||||
|
"update": "Ενημέρωση εγγραφής",
|
||||||
|
"smartFill": "Συμπλήρωση από την τελευταία καταχώριση"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"emptyTask": "Δεν καταγράφηκε ακόμη χρόνος για αυτήν την εργασία.",
|
||||||
|
"emptyFiltered": "Δεν καταγράφηκε χρόνος με βάση τα επιλεγμένα φίλτρα.",
|
||||||
|
"total": "Σύνολο",
|
||||||
|
"time": "Ώρα",
|
||||||
|
"duration": "Διάρκεια"
|
||||||
|
},
|
||||||
|
"browse": {
|
||||||
|
"selectRange": "Επιλέξτε ένα εύρος",
|
||||||
|
"userSearch": "Αναζήτηση για ένα χρήστη…"
|
||||||
|
}
|
||||||
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"units": {
|
"units": {
|
||||||
"seconds": "δευτερόλεπτο|δευτερόλεπτα",
|
"seconds": "δευτερόλεπτο|δευτερόλεπτα",
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@
|
||||||
"yyyy\/mm\/dd": "YYYY\/MM\/DD"
|
"yyyy\/mm\/dd": "YYYY\/MM\/DD"
|
||||||
},
|
},
|
||||||
"timeFormat": "Time format",
|
"timeFormat": "Time format",
|
||||||
|
"timeTrackingDefaultStart": "Time tracking smart-fill start time",
|
||||||
"timeFormatOptions": {
|
"timeFormatOptions": {
|
||||||
"12h": "12-hour (AM/PM)",
|
"12h": "12-hour (AM/PM)",
|
||||||
"24h": "24-hour (HH:mm)"
|
"24h": "24-hour (HH:mm)"
|
||||||
|
|
@ -399,6 +400,7 @@
|
||||||
"title": "Duplicate this project",
|
"title": "Duplicate this project",
|
||||||
"label": "Duplicate",
|
"label": "Duplicate",
|
||||||
"text": "Select a parent project which should hold the duplicated project:",
|
"text": "Select a parent project which should hold the duplicated project:",
|
||||||
|
"shares": "Copy shares (users, teams and link shares) to the duplicate",
|
||||||
"success": "The project was successfully duplicated."
|
"success": "The project was successfully duplicated."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
|
|
@ -477,7 +479,6 @@
|
||||||
"month": "Month",
|
"month": "Month",
|
||||||
"day": "Day",
|
"day": "Day",
|
||||||
"hour": "Hour",
|
"hour": "Hour",
|
||||||
"range": "Date Range",
|
|
||||||
"chartLabel": "Project Gantt Chart",
|
"chartLabel": "Project Gantt Chart",
|
||||||
"taskBarsForRow": "Task bars for row {rowId}",
|
"taskBarsForRow": "Task bars for row {rowId}",
|
||||||
"taskBarLabel": "Task: {task}. From {startDate} to {endDate}. {dateType}. Click to edit, drag to move.",
|
"taskBarLabel": "Task: {task}. From {startDate} to {endDate}. {dateType}. Click to edit, drag to move.",
|
||||||
|
|
@ -506,7 +507,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Limit: {limit}",
|
"limit": "Limit: {limit}",
|
||||||
"noLimit": "Not Set",
|
|
||||||
"doneBucket": "Done bucket",
|
"doneBucket": "Done bucket",
|
||||||
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
|
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
|
||||||
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
|
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
|
||||||
|
|
@ -790,7 +790,10 @@
|
||||||
"closeDialog": "Close dialog",
|
"closeDialog": "Close dialog",
|
||||||
"closeQuickActions": "Close quick actions",
|
"closeQuickActions": "Close quick actions",
|
||||||
"skipToContent": "Skip to main content",
|
"skipToContent": "Skip to main content",
|
||||||
"sortBy": "Sort by"
|
"sortBy": "Sort by",
|
||||||
|
"dateRange": "Date range",
|
||||||
|
"notSet": "Not set",
|
||||||
|
"user": "User"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"projectColor": "Project color",
|
"projectColor": "Project color",
|
||||||
|
|
@ -1000,6 +1003,7 @@
|
||||||
"repeatAfter": "Set Repeating Interval",
|
"repeatAfter": "Set Repeating Interval",
|
||||||
"percentDone": "Set Progress",
|
"percentDone": "Set Progress",
|
||||||
"attachments": "Add Attachments",
|
"attachments": "Add Attachments",
|
||||||
|
"timeTracking": "Track time",
|
||||||
"relatedTasks": "Add Relation",
|
"relatedTasks": "Add Relation",
|
||||||
"moveProject": "Move",
|
"moveProject": "Move",
|
||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
|
|
@ -1469,6 +1473,32 @@
|
||||||
"frontendVersion": "Frontend version: {version}",
|
"frontendVersion": "Frontend version: {version}",
|
||||||
"apiVersion": "API version: {version}"
|
"apiVersion": "API version: {version}"
|
||||||
},
|
},
|
||||||
|
"timeTracking": {
|
||||||
|
"title": "Time tracking",
|
||||||
|
"stop": "Stop timer",
|
||||||
|
"logTime": "Log time",
|
||||||
|
"editEntry": "Edit entry",
|
||||||
|
"form": {
|
||||||
|
"task": "Task",
|
||||||
|
"taskSearch": "Search for a task…",
|
||||||
|
"commentPlaceholder": "What did you work on?",
|
||||||
|
"save": "Save entry",
|
||||||
|
"startTimer": "Start timer",
|
||||||
|
"update": "Update entry",
|
||||||
|
"smartFill": "Fill from last entry"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"emptyTask": "No time tracked for this task yet.",
|
||||||
|
"emptyFiltered": "No time tracked for the selected filters.",
|
||||||
|
"total": "Total",
|
||||||
|
"time": "Time",
|
||||||
|
"duration": "Duration"
|
||||||
|
},
|
||||||
|
"browse": {
|
||||||
|
"selectRange": "Select a range",
|
||||||
|
"userSearch": "Search for a user…"
|
||||||
|
}
|
||||||
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"units": {
|
"units": {
|
||||||
"seconds": "second|seconds",
|
"seconds": "second|seconds",
|
||||||
|
|
|
||||||
|
|
@ -251,8 +251,7 @@
|
||||||
"default": "Predeterminado",
|
"default": "Predeterminado",
|
||||||
"month": "Mes",
|
"month": "Mes",
|
||||||
"day": "Día",
|
"day": "Día",
|
||||||
"hour": "Hora",
|
"hour": "Hora"
|
||||||
"range": "Rango de fechas"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Tabla",
|
"title": "Tabla",
|
||||||
|
|
@ -261,7 +260,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Límite: {limit}",
|
"limit": "Límite: {limit}",
|
||||||
"noLimit": "No Establecido",
|
|
||||||
"doneBucket": "Contenedor completado",
|
"doneBucket": "Contenedor completado",
|
||||||
"doneBucketHint": "Todas las tareas movidas a este contenedor se marcarán automáticamente como finalizadas.",
|
"doneBucketHint": "Todas las tareas movidas a este contenedor se marcarán automáticamente como finalizadas.",
|
||||||
"doneBucketHintExtended": "Todas las tareas movidas al contenedor completado se marcarán como finalizadas automáticamente. Todas las tareas marcadas como finalizadas desde otro lugar también se moverán.",
|
"doneBucketHintExtended": "Todas las tareas movidas al contenedor completado se marcarán como finalizadas automáticamente. Todas las tareas marcadas como finalizadas desde otro lugar también se moverán.",
|
||||||
|
|
|
||||||
|
|
@ -463,7 +463,6 @@
|
||||||
"month": "ماه",
|
"month": "ماه",
|
||||||
"day": "روز",
|
"day": "روز",
|
||||||
"hour": "ساعت",
|
"hour": "ساعت",
|
||||||
"range": "محدوده تاریخ",
|
|
||||||
"chartLabel": "نمودار گانت پروژه",
|
"chartLabel": "نمودار گانت پروژه",
|
||||||
"taskBarsForRow": "نوارهای وظیفه برای ردیف {rowId}",
|
"taskBarsForRow": "نوارهای وظیفه برای ردیف {rowId}",
|
||||||
"taskBarLabel": "وظیفه: {task}. از {startDate} تا {endDate}. {dateType}. برای ویرایش کلیک کنید، برای جابجایی بکشید.",
|
"taskBarLabel": "وظیفه: {task}. از {startDate} تا {endDate}. {dateType}. برای ویرایش کلیک کنید، برای جابجایی بکشید.",
|
||||||
|
|
@ -492,7 +491,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "کانبان",
|
"title": "کانبان",
|
||||||
"limit": "محدودیت: {limit}",
|
"limit": "محدودیت: {limit}",
|
||||||
"noLimit": "تنظیم نشده",
|
|
||||||
"doneBucket": "سطل انجام شده",
|
"doneBucket": "سطل انجام شده",
|
||||||
"doneBucketHint": "تمام وظایفی که به این سطل منتقل شوند به طور خودکار به عنوان انجام شده علامتگذاری میشوند.",
|
"doneBucketHint": "تمام وظایفی که به این سطل منتقل شوند به طور خودکار به عنوان انجام شده علامتگذاری میشوند.",
|
||||||
"doneBucketHintExtended": "تمام وظایفی که به سطل انجام شده منتقل شوند به طور خودکار علامتگذاری میشوند. همچنین تمام وظایفی که از جای دیگر به عنوان انجام شده علامتگذاری شوند نیز به اینجا منتقل خواهند شد.",
|
"doneBucketHintExtended": "تمام وظایفی که به سطل انجام شده منتقل شوند به طور خودکار علامتگذاری میشوند. همچنین تمام وظایفی که از جای دیگر به عنوان انجام شده علامتگذاری شوند نیز به اینجا منتقل خواهند شد.",
|
||||||
|
|
|
||||||
|
|
@ -347,8 +347,7 @@
|
||||||
"default": "Oletus",
|
"default": "Oletus",
|
||||||
"month": "Kuukausi",
|
"month": "Kuukausi",
|
||||||
"day": "Päivä",
|
"day": "Päivä",
|
||||||
"hour": "Tunti",
|
"hour": "Tunti"
|
||||||
"range": "Ajanjakso"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Taulukko",
|
"title": "Taulukko",
|
||||||
|
|
@ -357,7 +356,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Raja: {limit}",
|
"limit": "Raja: {limit}",
|
||||||
"noLimit": "Ei Asetettu",
|
|
||||||
"doneBucket": "Valmiit sarake",
|
"doneBucket": "Valmiit sarake",
|
||||||
"doneBucketHint": "Kaikki tähän sarakkeeseen lisätyt tehtävät merkitään automaattisesti valmiiksi.",
|
"doneBucketHint": "Kaikki tähän sarakkeeseen lisätyt tehtävät merkitään automaattisesti valmiiksi.",
|
||||||
"doneBucketHintExtended": "Kaikki tähän sarakkeeseen lisätyt tehtävät merkitään automaattisesti valmiiksi. Muualla valmiiksi merkityt tehtävät siirretään myös.",
|
"doneBucketHintExtended": "Kaikki tähän sarakkeeseen lisätyt tehtävät merkitään automaattisesti valmiiksi. Muualla valmiiksi merkityt tehtävät siirretään myös.",
|
||||||
|
|
|
||||||
|
|
@ -346,7 +346,6 @@
|
||||||
"month": "Mois",
|
"month": "Mois",
|
||||||
"day": "Jour",
|
"day": "Jour",
|
||||||
"hour": "Heure",
|
"hour": "Heure",
|
||||||
"range": "Intervalle",
|
|
||||||
"chartLabel": "Diagramme de Gantt du projet",
|
"chartLabel": "Diagramme de Gantt du projet",
|
||||||
"taskBarsForRow": "Barres de tâches pour la ligne {rowId}",
|
"taskBarsForRow": "Barres de tâches pour la ligne {rowId}",
|
||||||
"taskBarLabel": "Tâche : {task}. De {startDate} à {endDate}. {dateType}. Cliquez pour modifier, faites glisser pour déplacer.",
|
"taskBarLabel": "Tâche : {task}. De {startDate} à {endDate}. {dateType}. Cliquez pour modifier, faites glisser pour déplacer.",
|
||||||
|
|
@ -370,7 +369,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Limite : {limit}",
|
"limit": "Limite : {limit}",
|
||||||
"noLimit": "Non défini",
|
|
||||||
"doneBucket": "Colonne des tâches terminées",
|
"doneBucket": "Colonne des tâches terminées",
|
||||||
"doneBucketHint": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée.",
|
"doneBucketHint": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée.",
|
||||||
"doneBucketHintExtended": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée. Toute tâche marquée comme terminée ailleurs sera également déplacée.",
|
"doneBucketHintExtended": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée. Toute tâche marquée comme terminée ailleurs sera également déplacée.",
|
||||||
|
|
|
||||||
|
|
@ -318,8 +318,7 @@
|
||||||
"default": "ברירת מחדל",
|
"default": "ברירת מחדל",
|
||||||
"month": "חודש",
|
"month": "חודש",
|
||||||
"day": "יום",
|
"day": "יום",
|
||||||
"hour": "שעה",
|
"hour": "שעה"
|
||||||
"range": "טווח תאריכים"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "טבלה",
|
"title": "טבלה",
|
||||||
|
|
@ -328,7 +327,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "קאנבאן",
|
"title": "קאנבאן",
|
||||||
"limit": "הגבלה: {limit}",
|
"limit": "הגבלה: {limit}",
|
||||||
"noLimit": "לא נקבע",
|
|
||||||
"doneBucket": "דלי גמורים",
|
"doneBucket": "דלי גמורים",
|
||||||
"doneBucketHint": "דלי גמורים נשמר בהצלחה.",
|
"doneBucketHint": "דלי גמורים נשמר בהצלחה.",
|
||||||
"doneBucketHintExtended": "כל המטלות המוכנסות לדלי הגמורים יסומנו אוטומטית כגמורים. כל המטלות המסומנות כגמורים מבחוץ יוזזו גם.",
|
"doneBucketHintExtended": "כל המטלות המוכנסות לדלי הגמורים יסומנו אוטומטית כגמורים. כל המטלות המסומנות כגמורים מבחוץ יוזזו גם.",
|
||||||
|
|
|
||||||
|
|
@ -289,16 +289,14 @@
|
||||||
"default": "Zadano",
|
"default": "Zadano",
|
||||||
"month": "Mjesec",
|
"month": "Mjesec",
|
||||||
"day": "Dan",
|
"day": "Dan",
|
||||||
"hour": "Sat",
|
"hour": "Sat"
|
||||||
"range": "Raspon datuma"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Tablica",
|
"title": "Tablica",
|
||||||
"columns": "Stupci"
|
"columns": "Stupci"
|
||||||
},
|
},
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban"
|
||||||
"noLimit": "Nije postavljeno"
|
|
||||||
},
|
},
|
||||||
"pseudo": {
|
"pseudo": {
|
||||||
"favorites": {
|
"favorites": {
|
||||||
|
|
|
||||||
|
|
@ -290,8 +290,7 @@
|
||||||
"default": "Alapértelmezett",
|
"default": "Alapértelmezett",
|
||||||
"month": "Hónap",
|
"month": "Hónap",
|
||||||
"day": "Nap",
|
"day": "Nap",
|
||||||
"hour": "Óra",
|
"hour": "Óra"
|
||||||
"range": "Időintervallum"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Táblázat",
|
"title": "Táblázat",
|
||||||
|
|
@ -300,7 +299,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Korlát: {limit}",
|
"limit": "Korlát: {limit}",
|
||||||
"noLimit": "Nincs beállítva",
|
|
||||||
"doneBucket": "Kész vödör",
|
"doneBucket": "Kész vödör",
|
||||||
"doneBucketHint": "Az ebbe a csoportba helyezett összes feladat automatikusan készként lesz megjelölve.",
|
"doneBucketHint": "Az ebbe a csoportba helyezett összes feladat automatikusan készként lesz megjelölve.",
|
||||||
"doneBucketHintExtended": "A kész csoportba áthelyezett összes feladat automatikusan készként lesz megjelölve. A máshonnan elvégzettként megjelölt összes feladat is átkerül.",
|
"doneBucketHintExtended": "A kész csoportba áthelyezett összes feladat automatikusan készként lesz megjelölve. A máshonnan elvégzettként megjelölt összes feladat is átkerül.",
|
||||||
|
|
|
||||||
|
|
@ -362,7 +362,6 @@
|
||||||
"month": "Mese",
|
"month": "Mese",
|
||||||
"day": "Giorno",
|
"day": "Giorno",
|
||||||
"hour": "Ora",
|
"hour": "Ora",
|
||||||
"range": "Intervallo di date",
|
|
||||||
"chartLabel": "Progetto diagramma di Gantt",
|
"chartLabel": "Progetto diagramma di Gantt",
|
||||||
"taskBarsForRow": "Barre delle attività per riga {rowId}",
|
"taskBarsForRow": "Barre delle attività per riga {rowId}",
|
||||||
"taskBarLabel": "Attività: {task}. Da {startDate} a {endDate}. {dateType}. Clicca per modificare, trascina per spostare.",
|
"taskBarLabel": "Attività: {task}. Da {startDate} a {endDate}. {dateType}. Clicca per modificare, trascina per spostare.",
|
||||||
|
|
@ -386,7 +385,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Limite: {limit}",
|
"limit": "Limite: {limit}",
|
||||||
"noLimit": "Non Impostato",
|
|
||||||
"doneBucket": "Colonna attività completate",
|
"doneBucket": "Colonna attività completate",
|
||||||
"doneBucketHint": "Tutte le attività spostate in questa colonna verranno automaticamente contrassegnate come completate.",
|
"doneBucketHint": "Tutte le attività spostate in questa colonna verranno automaticamente contrassegnate come completate.",
|
||||||
"doneBucketHintExtended": "Tutte le attività spostate nella colonna attività completate saranno contrassegnate automaticamente come completate. Anche tutte le attività contrassegnate come completate altrove verranno spostate.",
|
"doneBucketHintExtended": "Tutte le attività spostate nella colonna attività completate saranno contrassegnate automaticamente come completate. Anche tutte le attività contrassegnate come completate altrove verranno spostate.",
|
||||||
|
|
|
||||||
|
|
@ -470,7 +470,6 @@
|
||||||
"month": "月",
|
"month": "月",
|
||||||
"day": "日",
|
"day": "日",
|
||||||
"hour": "時間",
|
"hour": "時間",
|
||||||
"range": "期間",
|
|
||||||
"chartLabel": "プロジェクトガントチャート",
|
"chartLabel": "プロジェクトガントチャート",
|
||||||
"taskBarsForRow": "行 {rowId} のタスクバー",
|
"taskBarsForRow": "行 {rowId} のタスクバー",
|
||||||
"taskBarLabel": "タスク: {task}。{startDate} から {endDate} まで。{dateType}。クリックして編集、ドラッグして移動。",
|
"taskBarLabel": "タスク: {task}。{startDate} から {endDate} まで。{dateType}。クリックして編集、ドラッグして移動。",
|
||||||
|
|
@ -499,7 +498,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "カンバン",
|
"title": "カンバン",
|
||||||
"limit": "上限: {limit}",
|
"limit": "上限: {limit}",
|
||||||
"noLimit": "未設定",
|
|
||||||
"doneBucket": "バケットを完了",
|
"doneBucket": "バケットを完了",
|
||||||
"doneBucketHint": "このバケットに移動されたすべてのタスクは自動的に完了としてマークされます。",
|
"doneBucketHint": "このバケットに移動されたすべてのタスクは自動的に完了としてマークされます。",
|
||||||
"doneBucketHintExtended": "完了バケットに移動されたすべてのタスクは自動的に完了としてマークされます。他の場所にあるタスクも完了としてマークされるとこのバケットに移動されます。",
|
"doneBucketHintExtended": "完了バケットに移動されたすべてのタスクは自動的に完了としてマークされます。他の場所にあるタスクも完了としてマークされるとこのバケットに移動されます。",
|
||||||
|
|
|
||||||
|
|
@ -323,8 +323,7 @@
|
||||||
"default": "기본값",
|
"default": "기본값",
|
||||||
"month": "월",
|
"month": "월",
|
||||||
"day": "일",
|
"day": "일",
|
||||||
"hour": "시",
|
"hour": "시"
|
||||||
"range": "날짜 범위"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "테이블",
|
"title": "테이블",
|
||||||
|
|
@ -333,7 +332,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "칸반",
|
"title": "칸반",
|
||||||
"limit": "제한: {limit}",
|
"limit": "제한: {limit}",
|
||||||
"noLimit": "설정 안함",
|
|
||||||
"doneBucket": "완료 버킷",
|
"doneBucket": "완료 버킷",
|
||||||
"doneBucketHint": "이 버킷으로 이동한 모든 할 일은 자동으로 완료로 표시됩니다.",
|
"doneBucketHint": "이 버킷으로 이동한 모든 할 일은 자동으로 완료로 표시됩니다.",
|
||||||
"doneBucketHintExtended": "완료 버킷으로 이동된 모든 할 일은 자동으로 완료로 표시됩니다. 다른 곳에서 완료로 표시된 모든 할 일도 함께 이동됩니다.",
|
"doneBucketHintExtended": "완료 버킷으로 이동된 모든 할 일은 자동으로 완료로 표시됩니다. 다른 곳에서 완료로 표시된 모든 할 일도 함께 이동됩니다.",
|
||||||
|
|
|
||||||
|
|
@ -320,8 +320,7 @@
|
||||||
"default": "Numatytasis",
|
"default": "Numatytasis",
|
||||||
"month": "Mėnuo",
|
"month": "Mėnuo",
|
||||||
"day": "Diena",
|
"day": "Diena",
|
||||||
"hour": "Valanda",
|
"hour": "Valanda"
|
||||||
"range": "Datos intervalas"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Lentelė",
|
"title": "Lentelė",
|
||||||
|
|
@ -330,7 +329,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanbanas",
|
"title": "Kanbanas",
|
||||||
"limit": "Limitas: {limit}",
|
"limit": "Limitas: {limit}",
|
||||||
"noLimit": "Nenustatytas",
|
|
||||||
"doneBucket": "Atliktųjų telkinys",
|
"doneBucket": "Atliktųjų telkinys",
|
||||||
"doneBucketHint": "Visos užduotys nukreiptos į šį telkinį bus automatiškai pažymėtos kaip atliktos.",
|
"doneBucketHint": "Visos užduotys nukreiptos į šį telkinį bus automatiškai pažymėtos kaip atliktos.",
|
||||||
"doneBucketHintExtended": "Visos užduotys perkeltos į atliktą telkiny bus automatiškai pažymėtos kaip atliktos. Visos užduotys pažymėtos kaip atliktos iš kitur bus perkeltos taip pat.",
|
"doneBucketHintExtended": "Visos užduotys perkeltos į atliktą telkiny bus automatiškai pažymėtos kaip atliktos. Visos užduotys pažymėtos kaip atliktos iš kitur bus perkeltos taip pat.",
|
||||||
|
|
|
||||||
|
|
@ -470,7 +470,6 @@
|
||||||
"month": "Maand",
|
"month": "Maand",
|
||||||
"day": "Dag",
|
"day": "Dag",
|
||||||
"hour": "Uur",
|
"hour": "Uur",
|
||||||
"range": "Datumbereik",
|
|
||||||
"chartLabel": "Project Gantt-diagram",
|
"chartLabel": "Project Gantt-diagram",
|
||||||
"taskBarsForRow": "Taakbalken voor rij {rowId}",
|
"taskBarsForRow": "Taakbalken voor rij {rowId}",
|
||||||
"taskBarLabel": "Taak: {task}. Van {startDate} tot {endDate}. {dateType}. Klik om te bewerken, sleep om te verplaatsen.",
|
"taskBarLabel": "Taak: {task}. Van {startDate} tot {endDate}. {dateType}. Klik om te bewerken, sleep om te verplaatsen.",
|
||||||
|
|
@ -499,7 +498,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Limiet: {limit}",
|
"limit": "Limiet: {limit}",
|
||||||
"noLimit": "Niet ingesteld",
|
|
||||||
"doneBucket": "Categorie 'voltooid'",
|
"doneBucket": "Categorie 'voltooid'",
|
||||||
"doneBucketHint": "Alle taken die je naar deze categorie verplaatst worden automatisch als 'voltooid' gemarkeerd.",
|
"doneBucketHint": "Alle taken die je naar deze categorie verplaatst worden automatisch als 'voltooid' gemarkeerd.",
|
||||||
"doneBucketHintExtended": "Taken die je verplaatst naar de categorie 'voltooid' worden automatisch gemarkeerd als voltooid. Ook taken die je elders als voltooid aanmerkt, worden hierheen verplaatst.",
|
"doneBucketHintExtended": "Taken die je verplaatst naar de categorie 'voltooid' worden automatisch gemarkeerd als voltooid. Ook taken die je elders als voltooid aanmerkt, worden hierheen verplaatst.",
|
||||||
|
|
|
||||||
|
|
@ -353,7 +353,6 @@
|
||||||
"month": "Måned",
|
"month": "Måned",
|
||||||
"day": "Dag",
|
"day": "Dag",
|
||||||
"hour": "Time",
|
"hour": "Time",
|
||||||
"range": "Datointervall",
|
|
||||||
"chartLabel": "Gantt-kart for prosjekt",
|
"chartLabel": "Gantt-kart for prosjekt",
|
||||||
"taskBarsForRow": "Oppgavelinjer for rad {rowId}",
|
"taskBarsForRow": "Oppgavelinjer for rad {rowId}",
|
||||||
"taskBarLabel": "Oppgave: {task}. Fra {startDate} til {endDate}. {dateType}. Klikk for å redigere, dra for å flytte.",
|
"taskBarLabel": "Oppgave: {task}. Fra {startDate} til {endDate}. {dateType}. Klikk for å redigere, dra for å flytte.",
|
||||||
|
|
@ -377,7 +376,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Begrens: {limit}",
|
"limit": "Begrens: {limit}",
|
||||||
"noLimit": "Ikke angitt",
|
|
||||||
"doneBucket": "Ferdigkurv",
|
"doneBucket": "Ferdigkurv",
|
||||||
"doneBucketHint": "Alle oppgaver som flyttes til denne kurven vil automatisk bli markert som ferdige.",
|
"doneBucketHint": "Alle oppgaver som flyttes til denne kurven vil automatisk bli markert som ferdige.",
|
||||||
"doneBucketHintExtended": "Alle oppgaver som er flyttet til ferdigkurven, vil automatisk bli markert som ferdige. Alle oppgaver markert som ferdige fra andre steder vil også bli flyttet.",
|
"doneBucketHintExtended": "Alle oppgaver som er flyttet til ferdigkurven, vil automatisk bli markert som ferdige. Alle oppgaver markert som ferdige fra andre steder vil også bli flyttet.",
|
||||||
|
|
|
||||||
|
|
@ -300,8 +300,7 @@
|
||||||
"default": "Domyślnie",
|
"default": "Domyślnie",
|
||||||
"month": "Miesiąc",
|
"month": "Miesiąc",
|
||||||
"day": "Dzień",
|
"day": "Dzień",
|
||||||
"hour": "Godzina",
|
"hour": "Godzina"
|
||||||
"range": "Zakres dat"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Tabela",
|
"title": "Tabela",
|
||||||
|
|
@ -310,7 +309,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Limit: {limit}",
|
"limit": "Limit: {limit}",
|
||||||
"noLimit": "Nie ustawiony",
|
|
||||||
"doneBucket": "Zakończone zadania",
|
"doneBucket": "Zakończone zadania",
|
||||||
"doneBucketHint": "Wszystkie zadania przeniesione do tej kolumny zostaną automatycznie oznaczone jako zakończone.",
|
"doneBucketHint": "Wszystkie zadania przeniesione do tej kolumny zostaną automatycznie oznaczone jako zakończone.",
|
||||||
"doneBucketHintExtended": "Wszystkie zadania przeniesione do kolumny zakończonych zostaną automatycznie oznaczone jako zakończone. Wszystkie zadania oznaczone jako zakończone z innych miejsc zostaną przeniesione.",
|
"doneBucketHintExtended": "Wszystkie zadania przeniesione do kolumny zakończonych zostaną automatycznie oznaczone jako zakończone. Wszystkie zadania oznaczone jako zakończone z innych miejsc zostaną przeniesione.",
|
||||||
|
|
|
||||||
|
|
@ -286,8 +286,7 @@
|
||||||
"default": "Padrão",
|
"default": "Padrão",
|
||||||
"month": "Mês",
|
"month": "Mês",
|
||||||
"day": "Dia",
|
"day": "Dia",
|
||||||
"hour": "Hora",
|
"hour": "Hora"
|
||||||
"range": "Período"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Tabela",
|
"title": "Tabela",
|
||||||
|
|
@ -296,7 +295,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Limite: {limit}",
|
"limit": "Limite: {limit}",
|
||||||
"noLimit": "Não definido",
|
|
||||||
"doneBucket": "Bucket concluído",
|
"doneBucket": "Bucket concluído",
|
||||||
"doneBucketHint": "Todas as tarefas movidas para este bucket serão marcadas automaticamente como concluídas.",
|
"doneBucketHint": "Todas as tarefas movidas para este bucket serão marcadas automaticamente como concluídas.",
|
||||||
"doneBucketHintExtended": "Todas as tarefas movidas para o bucket concluído serão automaticamente concluídas também. Todas as tarefas concluídas de outro lugar serão movidas também.",
|
"doneBucketHintExtended": "Todas as tarefas movidas para o bucket concluído serão automaticamente concluídas também. Todas as tarefas concluídas de outro lugar serão movidas também.",
|
||||||
|
|
|
||||||
|
|
@ -362,7 +362,6 @@
|
||||||
"month": "Mês",
|
"month": "Mês",
|
||||||
"day": "Dia",
|
"day": "Dia",
|
||||||
"hour": "Hora",
|
"hour": "Hora",
|
||||||
"range": "Intervalo de Datas",
|
|
||||||
"chartLabel": "Gráfico de Gantt do projeto",
|
"chartLabel": "Gráfico de Gantt do projeto",
|
||||||
"taskBarsForRow": "Barras de tarefas para a linha {rowId}",
|
"taskBarsForRow": "Barras de tarefas para a linha {rowId}",
|
||||||
"taskBarLabel": "Tarefa: {task}. De {startDate} a {endDate}. {dateType}. Clica para editar, arrasta para mover.",
|
"taskBarLabel": "Tarefa: {task}. De {startDate} a {endDate}. {dateType}. Clica para editar, arrasta para mover.",
|
||||||
|
|
@ -386,7 +385,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Limite: {limit}",
|
"limit": "Limite: {limit}",
|
||||||
"noLimit": "Não Definido",
|
|
||||||
"doneBucket": "Conjunto concluído",
|
"doneBucket": "Conjunto concluído",
|
||||||
"doneBucketHint": "Todas as tarefas movidas para este conjunto serão automaticamente marcadas como concluídas.",
|
"doneBucketHint": "Todas as tarefas movidas para este conjunto serão automaticamente marcadas como concluídas.",
|
||||||
"doneBucketHintExtended": "Todas as tarefas movidas para o conjunto concluído serão marcadas automaticamente como concluídas. Todas as tarefas marcadas como concluídas em outro lugar também serão movidas.",
|
"doneBucketHintExtended": "Todas as tarefas movidas para o conjunto concluído serão marcadas automaticamente como concluídas. Todas as tarefas marcadas como concluídas em outro lugar também serão movidas.",
|
||||||
|
|
|
||||||
|
|
@ -407,7 +407,6 @@
|
||||||
"month": "Месяц",
|
"month": "Месяц",
|
||||||
"day": "День",
|
"day": "День",
|
||||||
"hour": "Час",
|
"hour": "Час",
|
||||||
"range": "Диапазон",
|
|
||||||
"chartLabel": "Диаграмма Ганта",
|
"chartLabel": "Диаграмма Ганта",
|
||||||
"taskBarsForRow": "Задачи в строке {rowId}",
|
"taskBarsForRow": "Задачи в строке {rowId}",
|
||||||
"taskBarLabel": "Задача: {task}. С {startDate} по {endDate}. {dateType}. Нажмите для изменения, потяните для перемещения.",
|
"taskBarLabel": "Задача: {task}. С {startDate} по {endDate}. {dateType}. Нажмите для изменения, потяните для перемещения.",
|
||||||
|
|
@ -435,7 +434,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Канбан",
|
"title": "Канбан",
|
||||||
"limit": "Лимит: {limit}",
|
"limit": "Лимит: {limit}",
|
||||||
"noLimit": "не установлен",
|
|
||||||
"doneBucket": "Колонка завершённых",
|
"doneBucket": "Колонка завершённых",
|
||||||
"doneBucketHint": "Все задачи, помещённые в эту колонку, автоматически отмечаются как завершённые.",
|
"doneBucketHint": "Все задачи, помещённые в эту колонку, автоматически отмечаются как завершённые.",
|
||||||
"doneBucketHintExtended": "Все задачи, перенесённые в колонку завершённых, будут помечены как завершённые. Все задачи, помеченные как завершённые, также будут перемещены в эту колонку.",
|
"doneBucketHintExtended": "Все задачи, перенесённые в колонку завершённых, будут помечены как завершённые. Все задачи, помеченные как завершённые, также будут перемещены в эту колонку.",
|
||||||
|
|
|
||||||
|
|
@ -314,8 +314,7 @@
|
||||||
"default": "Privzeto",
|
"default": "Privzeto",
|
||||||
"month": "Mesec",
|
"month": "Mesec",
|
||||||
"day": "Dan",
|
"day": "Dan",
|
||||||
"hour": "Ura",
|
"hour": "Ura"
|
||||||
"range": "Datumski obseg"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Tabela",
|
"title": "Tabela",
|
||||||
|
|
@ -324,7 +323,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Omejitev: {limit}",
|
"limit": "Omejitev: {limit}",
|
||||||
"noLimit": "Ni nastavljeno",
|
|
||||||
"doneBucket": "Vedro končanih nalog",
|
"doneBucket": "Vedro končanih nalog",
|
||||||
"doneBucketHint": "Vse naloge, premaknjene v to vedro, bodo samodejno označene kot opravljene.",
|
"doneBucketHint": "Vse naloge, premaknjene v to vedro, bodo samodejno označene kot opravljene.",
|
||||||
"doneBucketHintExtended": "Vse naloge, premaknjene v vedro končanih nalog, bodo samodejno označene kot opravljene. Premaknjene bodo tudi vse naloge, ki so od drugje označena kot opravljene.",
|
"doneBucketHintExtended": "Vse naloge, premaknjene v vedro končanih nalog, bodo samodejno označene kot opravljene. Premaknjene bodo tudi vse naloge, ki so od drugje označena kot opravljene.",
|
||||||
|
|
|
||||||
|
|
@ -362,7 +362,6 @@
|
||||||
"month": "Månad",
|
"month": "Månad",
|
||||||
"day": "Dag",
|
"day": "Dag",
|
||||||
"hour": "Timme",
|
"hour": "Timme",
|
||||||
"range": "Datumintervall",
|
|
||||||
"chartLabel": "Projektets Gantt-schema",
|
"chartLabel": "Projektets Gantt-schema",
|
||||||
"taskBarsForRow": "Uppgiftsstaplar för rad {rowId}",
|
"taskBarsForRow": "Uppgiftsstaplar för rad {rowId}",
|
||||||
"taskBarLabel": "Uppgift: {task}. Från {startDate} till {endDate}. {dateType}. Klicka för att redigera, dra för att flytta.",
|
"taskBarLabel": "Uppgift: {task}. Från {startDate} till {endDate}. {dateType}. Klicka för att redigera, dra för att flytta.",
|
||||||
|
|
@ -386,7 +385,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Gräns: {limit}",
|
"limit": "Gräns: {limit}",
|
||||||
"noLimit": "Ej inställt",
|
|
||||||
"doneBucket": "Färdigkolumn",
|
"doneBucket": "Färdigkolumn",
|
||||||
"doneBucketHint": "Alla uppgifter som flyttas till den här kolumnen markeras automatiskt som klara.",
|
"doneBucketHint": "Alla uppgifter som flyttas till den här kolumnen markeras automatiskt som klara.",
|
||||||
"doneBucketHintExtended": "Alla uppgifter som flyttas till färdigkolumnen markeras som färdiga automatiskt. Alla uppgifter som markerats som färdiga från andra håll kommer också att flyttas.",
|
"doneBucketHintExtended": "Alla uppgifter som flyttas till färdigkolumnen markeras som färdiga automatiskt. Alla uppgifter som markerats som färdiga från andra håll kommer också att flyttas.",
|
||||||
|
|
|
||||||
|
|
@ -362,7 +362,6 @@
|
||||||
"month": "Ay",
|
"month": "Ay",
|
||||||
"day": "Gün",
|
"day": "Gün",
|
||||||
"hour": "Saat",
|
"hour": "Saat",
|
||||||
"range": "Tarih Aralığı",
|
|
||||||
"chartLabel": "Proje Gantt Şeması",
|
"chartLabel": "Proje Gantt Şeması",
|
||||||
"taskBarsForRow": "{rowId} satırı için görev çubukları",
|
"taskBarsForRow": "{rowId} satırı için görev çubukları",
|
||||||
"taskBarLabel": "Görev: {task}. {startDate} tarihinden {endDate} tarihine. {dateType}. Düzenlemek için tıklayın, taşımak için sürükleyin.",
|
"taskBarLabel": "Görev: {task}. {startDate} tarihinden {endDate} tarihine. {dateType}. Düzenlemek için tıklayın, taşımak için sürükleyin.",
|
||||||
|
|
@ -386,7 +385,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Sınır: {limit}",
|
"limit": "Sınır: {limit}",
|
||||||
"noLimit": "Belirlenmedi",
|
|
||||||
"doneBucket": "Tamamlananlar kutusu",
|
"doneBucket": "Tamamlananlar kutusu",
|
||||||
"doneBucketHint": "Bu kutuya taşınan tüm görevler otomatik olarak tamamlandı olarak işaretlenir.",
|
"doneBucketHint": "Bu kutuya taşınan tüm görevler otomatik olarak tamamlandı olarak işaretlenir.",
|
||||||
"doneBucketHintExtended": "Tamamlananlar kutusuna taşınan tüm görevler otomatik olarak tamamlandı olarak işaretlenir. Başka bir yerden tamamlandı olarak işaretlenen görevler de buraya taşınır.",
|
"doneBucketHintExtended": "Tamamlananlar kutusuna taşınan tüm görevler otomatik olarak tamamlandı olarak işaretlenir. Başka bir yerden tamamlandı olarak işaretlenen görevler de buraya taşınır.",
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,7 @@
|
||||||
"yyyy/mm/dd": "YYYY/MM/DD"
|
"yyyy/mm/dd": "YYYY/MM/DD"
|
||||||
},
|
},
|
||||||
"timeFormat": "Формат часу",
|
"timeFormat": "Формат часу",
|
||||||
|
"timeTrackingDefaultStart": "Початковий час для автоматичного заповнення обліку часу",
|
||||||
"timeFormatOptions": {
|
"timeFormatOptions": {
|
||||||
"12h": "12-годинний (AM/PM)",
|
"12h": "12-годинний (AM/PM)",
|
||||||
"24h": "24-годинний (HH:mm)"
|
"24h": "24-годинний (HH:mm)"
|
||||||
|
|
@ -392,6 +393,7 @@
|
||||||
"title": "Дублювати цей проєкт",
|
"title": "Дублювати цей проєкт",
|
||||||
"label": "Дублювати",
|
"label": "Дублювати",
|
||||||
"text": "Оберіть батьківський проєкт, який повинен складатися з дубльованих проєктів:",
|
"text": "Оберіть батьківський проєкт, який повинен складатися з дубльованих проєктів:",
|
||||||
|
"shares": "Скопіювати налаштування спільного доступу (користувачів, команди та посилання) до копії проєкту",
|
||||||
"success": "Проєкт дубльовано."
|
"success": "Проєкт дубльовано."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
|
|
@ -470,7 +472,6 @@
|
||||||
"month": "Місяць",
|
"month": "Місяць",
|
||||||
"day": "День",
|
"day": "День",
|
||||||
"hour": "Година",
|
"hour": "Година",
|
||||||
"range": "Проміжок днів",
|
|
||||||
"chartLabel": "Діаграма Ганта",
|
"chartLabel": "Діаграма Ганта",
|
||||||
"taskBarsForRow": "Смуги завдань для рядка {rowId}",
|
"taskBarsForRow": "Смуги завдань для рядка {rowId}",
|
||||||
"taskBarLabel": "Завдання: {task}. З {startDate} по {endDate}. {dateType}. Натисніть, щоб редагувати, перетягніть, щоб перемістити.",
|
"taskBarLabel": "Завдання: {task}. З {startDate} по {endDate}. {dateType}. Натисніть, щоб редагувати, перетягніть, щоб перемістити.",
|
||||||
|
|
@ -499,7 +500,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Дошка",
|
"title": "Дошка",
|
||||||
"limit": "Межа: {limit}",
|
"limit": "Межа: {limit}",
|
||||||
"noLimit": "Немає",
|
|
||||||
"doneBucket": "Колонка «Виконано»",
|
"doneBucket": "Колонка «Виконано»",
|
||||||
"doneBucketHint": "Всі завдання, переміщені в цю колонку, будуть автоматично позначені як виконані.",
|
"doneBucketHint": "Всі завдання, переміщені в цю колонку, будуть автоматично позначені як виконані.",
|
||||||
"doneBucketHintExtended": "Всі завдання, що потрапляють у колонку «Виконано», автоматично відмічаються як виконані. Завдання, позначені як виконані в інших місцях, також перемістяться сюди.",
|
"doneBucketHintExtended": "Всі завдання, що потрапляють у колонку «Виконано», автоматично відмічаються як виконані. Завдання, позначені як виконані в інших місцях, також перемістяться сюди.",
|
||||||
|
|
@ -783,7 +783,10 @@
|
||||||
"closeDialog": "Закрити діалог",
|
"closeDialog": "Закрити діалог",
|
||||||
"closeQuickActions": "Закрити швидкі дії",
|
"closeQuickActions": "Закрити швидкі дії",
|
||||||
"skipToContent": "Перейти до основного вмісту",
|
"skipToContent": "Перейти до основного вмісту",
|
||||||
"sortBy": "Сортувати за"
|
"sortBy": "Сортувати за",
|
||||||
|
"dateRange": "Діапазон дат",
|
||||||
|
"notSet": "Не встановлено",
|
||||||
|
"user": "Користувач"
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"projectColor": "Колір проєкту",
|
"projectColor": "Колір проєкту",
|
||||||
|
|
@ -986,13 +989,14 @@
|
||||||
"assign": "Доручити",
|
"assign": "Доручити",
|
||||||
"label": "Позначки",
|
"label": "Позначки",
|
||||||
"priority": "Встановити пріоритет",
|
"priority": "Встановити пріоритет",
|
||||||
"dueDate": "Встановити термін",
|
"dueDate": "Встановити термін виконання",
|
||||||
"startDate": "Почати",
|
"startDate": "Почати",
|
||||||
"endDate": "Встановити дату завершення",
|
"endDate": "Встановити дату завершення",
|
||||||
"reminders": "Нагадування",
|
"reminders": "Нагадування",
|
||||||
"repeatAfter": "Повторювати",
|
"repeatAfter": "Повторювати",
|
||||||
"percentDone": "Встановити прогрес",
|
"percentDone": "Встановити прогрес",
|
||||||
"attachments": "Вкласти",
|
"attachments": "Вкласти",
|
||||||
|
"timeTracking": "Відстежити час",
|
||||||
"relatedTasks": "Пов'язати",
|
"relatedTasks": "Пов'язати",
|
||||||
"moveProject": "Перемістити",
|
"moveProject": "Перемістити",
|
||||||
"duplicate": "Дублювати",
|
"duplicate": "Дублювати",
|
||||||
|
|
@ -1148,6 +1152,7 @@
|
||||||
"repeat": {
|
"repeat": {
|
||||||
"everyDay": "Щодня",
|
"everyDay": "Щодня",
|
||||||
"everyWeek": "Щотижня",
|
"everyWeek": "Щотижня",
|
||||||
|
"every30d": "Кожні 30 днів",
|
||||||
"mode": "Спосіб",
|
"mode": "Спосіб",
|
||||||
"monthly": "Щомісяця",
|
"monthly": "Щомісяця",
|
||||||
"fromCurrentDate": "З дня закінчення",
|
"fromCurrentDate": "З дня закінчення",
|
||||||
|
|
@ -1461,6 +1466,32 @@
|
||||||
"frontendVersion": "Версія інтерфейсу: {version}",
|
"frontendVersion": "Версія інтерфейсу: {version}",
|
||||||
"apiVersion": "API версія: {version}"
|
"apiVersion": "API версія: {version}"
|
||||||
},
|
},
|
||||||
|
"timeTracking": {
|
||||||
|
"title": "Відстеження часу",
|
||||||
|
"stop": "Зупинити таймер",
|
||||||
|
"logTime": "Записати час",
|
||||||
|
"editEntry": "Редагувати запис",
|
||||||
|
"form": {
|
||||||
|
"task": "Завдання",
|
||||||
|
"taskSearch": "Знайти завдання…",
|
||||||
|
"commentPlaceholder": "Над чим ви працювали?",
|
||||||
|
"save": "Зберегти запис",
|
||||||
|
"startTimer": "Запустити таймер",
|
||||||
|
"update": "Оновити запис",
|
||||||
|
"smartFill": "Заповнити з останнього запису"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"emptyTask": "Для цього завдання ще немає записів обліку часу.",
|
||||||
|
"emptyFiltered": "Немає записів обліку часу для вибраних фільтрів.",
|
||||||
|
"total": "Загалом",
|
||||||
|
"time": "Час",
|
||||||
|
"duration": "Тривалість"
|
||||||
|
},
|
||||||
|
"browse": {
|
||||||
|
"selectRange": "Обрати діапазон",
|
||||||
|
"userSearch": "Знайти користувача…"
|
||||||
|
}
|
||||||
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"units": {
|
"units": {
|
||||||
"seconds": "секунда|секунд(и)",
|
"seconds": "секунда|секунд(и)",
|
||||||
|
|
|
||||||
|
|
@ -319,8 +319,7 @@
|
||||||
"default": "Mặc định",
|
"default": "Mặc định",
|
||||||
"month": "Tháng",
|
"month": "Tháng",
|
||||||
"day": "Ngày",
|
"day": "Ngày",
|
||||||
"hour": "Giờ",
|
"hour": "Giờ"
|
||||||
"range": "Khoảng thời gian"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"title": "Bảng",
|
"title": "Bảng",
|
||||||
|
|
@ -329,7 +328,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "Kanban",
|
"title": "Kanban",
|
||||||
"limit": "Giới hạn: {limit}",
|
"limit": "Giới hạn: {limit}",
|
||||||
"noLimit": "Không giới hạn",
|
|
||||||
"doneBucket": "Cột hoàn thành",
|
"doneBucket": "Cột hoàn thành",
|
||||||
"doneBucketHint": "Tất cả các tác vụ được chuyển đến cột này sẽ được tự động đánh dấu là hoàn thành.",
|
"doneBucketHint": "Tất cả các tác vụ được chuyển đến cột này sẽ được tự động đánh dấu là hoàn thành.",
|
||||||
"doneBucketHintExtended": "Tất cả các tác vụ được đưa đến cột hoàn thành sẽ được tự động đánh dấu là hoàn thành. Tất cả các tác vụ hoàn thành từ các phần khác cũng sẽ được chuyển tới đây.",
|
"doneBucketHintExtended": "Tất cả các tác vụ được đưa đến cột hoàn thành sẽ được tự động đánh dấu là hoàn thành. Tất cả các tác vụ hoàn thành từ các phần khác cũng sẽ được chuyển tới đây.",
|
||||||
|
|
|
||||||
|
|
@ -338,7 +338,6 @@
|
||||||
"month": "月",
|
"month": "月",
|
||||||
"day": "日",
|
"day": "日",
|
||||||
"hour": "时",
|
"hour": "时",
|
||||||
"range": "日期范围",
|
|
||||||
"chartLabel": "项目甘特图",
|
"chartLabel": "项目甘特图",
|
||||||
"scheduledDates": "预定日期",
|
"scheduledDates": "预定日期",
|
||||||
"estimatedDates": "估计日期"
|
"estimatedDates": "估计日期"
|
||||||
|
|
@ -350,7 +349,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "看板",
|
"title": "看板",
|
||||||
"limit": "限制: {limit}",
|
"limit": "限制: {limit}",
|
||||||
"noLimit": "未设置",
|
|
||||||
"doneBucket": "已完成的桶数",
|
"doneBucket": "已完成的桶数",
|
||||||
"doneBucketHint": "移入此存储桶的所有任务将自动标记为已完成。",
|
"doneBucketHint": "移入此存储桶的所有任务将自动标记为已完成。",
|
||||||
"doneBucketHintExtended": "所有移入已完成存储桶的任务都将自动标记为已完成。 从其他位置标记为已完成的任务也将被移动。",
|
"doneBucketHintExtended": "所有移入已完成存储桶的任务都将自动标记为已完成。 从其他位置标记为已完成的任务也将被移动。",
|
||||||
|
|
|
||||||
|
|
@ -362,7 +362,6 @@
|
||||||
"month": "月",
|
"month": "月",
|
||||||
"day": "日",
|
"day": "日",
|
||||||
"hour": "時",
|
"hour": "時",
|
||||||
"range": "日期範圍",
|
|
||||||
"chartLabel": "專案甘特圖",
|
"chartLabel": "專案甘特圖",
|
||||||
"taskBarsForRow": "第 {rowId} 列的任務列",
|
"taskBarsForRow": "第 {rowId} 列的任務列",
|
||||||
"taskBarLabel": "任務:{task}。從 {startDate} 到 {endDate}。{dateType}。點擊編輯,拖曳移動。",
|
"taskBarLabel": "任務:{task}。從 {startDate} 到 {endDate}。{dateType}。點擊編輯,拖曳移動。",
|
||||||
|
|
@ -386,7 +385,6 @@
|
||||||
"kanban": {
|
"kanban": {
|
||||||
"title": "看板",
|
"title": "看板",
|
||||||
"limit": "限制: {limit}",
|
"limit": "限制: {limit}",
|
||||||
"noLimit": "未設定",
|
|
||||||
"doneBucket": "已完成類別",
|
"doneBucket": "已完成類別",
|
||||||
"doneBucketHint": "移入此類別的任務將自動標記為已完成。",
|
"doneBucketHint": "移入此類別的任務將自動標記為已完成。",
|
||||||
"doneBucketHintExtended": "所有移入已完成類別的任務將自動標記為已完成。 其他位置標記為已完成的任務也將被移動。",
|
"doneBucketHintExtended": "所有移入已完成類別的任務將自動標記為已完成。 其他位置標記為已完成的任務也將被移動。",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export const DAYJS_LOCALE_MAPPING = {
|
||||||
'ja-jp': 'ja',
|
'ja-jp': 'ja',
|
||||||
'hu-hu': 'hu',
|
'hu-hu': 'hu',
|
||||||
'ar-sa': 'ar-sa',
|
'ar-sa': 'ar-sa',
|
||||||
|
'fa-ir': 'fa',
|
||||||
'sl-si': 'sl',
|
'sl-si': 'sl',
|
||||||
'pt-br': 'pt',
|
'pt-br': 'pt',
|
||||||
'hr-hr': 'hr',
|
'hr-hr': 'hr',
|
||||||
|
|
@ -55,6 +56,7 @@ export const DAYJS_LANGUAGE_IMPORTS = {
|
||||||
'ja-jp': () => import('dayjs/locale/ja'),
|
'ja-jp': () => import('dayjs/locale/ja'),
|
||||||
'hu-hu': () => import('dayjs/locale/hu'),
|
'hu-hu': () => import('dayjs/locale/hu'),
|
||||||
'ar-sa': () => import('dayjs/locale/ar-sa'),
|
'ar-sa': () => import('dayjs/locale/ar-sa'),
|
||||||
|
'fa-ir': () => import('dayjs/locale/fa'),
|
||||||
'sl-si': () => import('dayjs/locale/sl'),
|
'sl-si': () => import('dayjs/locale/sl'),
|
||||||
'pt-br': () => import('dayjs/locale/pt-br'),
|
'pt-br': () => import('dayjs/locale/pt-br'),
|
||||||
'hr-hr': () => import('dayjs/locale/hr'),
|
'hr-hr': () => import('dayjs/locale/hr'),
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,5 @@ export interface IProjectDuplicate extends IAbstract {
|
||||||
projectId: number
|
projectId: number
|
||||||
duplicatedProject: IProject | null
|
duplicatedProject: IProject | null
|
||||||
parentProjectId: IProject['id']
|
parentProjectId: IProject['id']
|
||||||
|
duplicateShares: boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export interface ITask extends IAbstract {
|
||||||
reactions: IReactionPerEntity
|
reactions: IReactionPerEntity
|
||||||
comments: ITaskComment[]
|
comments: ITaskComment[]
|
||||||
commentCount?: number
|
commentCount?: number
|
||||||
|
timeEntriesCount?: number
|
||||||
|
|
||||||
createdBy: IUser
|
createdBy: IUser
|
||||||
created: Date
|
created: Date
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type {IAbstract} from './IAbstract'
|
||||||
|
|
||||||
|
export interface ITimeEntry extends IAbstract {
|
||||||
|
id: number
|
||||||
|
userId: number
|
||||||
|
// Exactly one of taskId / projectId is set (0 means unset).
|
||||||
|
taskId: number
|
||||||
|
projectId: number
|
||||||
|
startTime: Date
|
||||||
|
// null while the live timer is running.
|
||||||
|
endTime: Date | null
|
||||||
|
comment: string
|
||||||
|
|
||||||
|
created: Date
|
||||||
|
updated: Date
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ export interface IFrontendSettings {
|
||||||
defaultPage: DefaultPage
|
defaultPage: DefaultPage
|
||||||
desktopQuickEntryShortcut: string
|
desktopQuickEntryShortcut: string
|
||||||
quickAddDefaultReminders: ITaskReminder[]
|
quickAddDefaultReminders: ITaskReminder[]
|
||||||
|
timeTrackingDefaultStart?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IExtraSettingsLink {
|
export interface IExtraSettingsLink {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export default class ProjectDuplicateModel extends AbstractModel<IProjectDuplica
|
||||||
projectId = 0
|
projectId = 0
|
||||||
duplicatedProject: IProject | null = null
|
duplicatedProject: IProject | null = null
|
||||||
parentProjectId = 0
|
parentProjectId = 0
|
||||||
|
duplicateShares = false
|
||||||
|
|
||||||
constructor(data : Partial<IProjectDuplicate>) {
|
constructor(data : Partial<IProjectDuplicate>) {
|
||||||
super()
|
super()
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ import {getProjectViewId} from '@/helpers/projectView'
|
||||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||||
import {getNextWeekDate} from '@/helpers/time/getNextWeekDate'
|
import {getNextWeekDate} from '@/helpers/time/getNextWeekDate'
|
||||||
import {LINK_SHARE_HASH_PREFIX} from '@/constants/linkShareHash'
|
import {LINK_SHARE_HASH_PREFIX} from '@/constants/linkShareHash'
|
||||||
|
import {REDIRECT_HASH_PREFIX} from '@/constants/redirectHash'
|
||||||
import {AUTH_ROUTE_NAMES} from '@/constants/authRouteNames'
|
import {AUTH_ROUTE_NAMES} from '@/constants/authRouteNames'
|
||||||
import {DEFAULT_PAGE} from '@/constants/defaultPage'
|
import {DEFAULT_PAGE} from '@/constants/defaultPage'
|
||||||
|
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||||
|
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
|
@ -31,7 +33,7 @@ const router = createRouter({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to anchor should still work
|
// Scroll to anchor should still work
|
||||||
if (to.hash && !to.hash.startsWith(LINK_SHARE_HASH_PREFIX)) {
|
if (to.hash && !to.hash.startsWith(LINK_SHARE_HASH_PREFIX) && !to.hash.startsWith(REDIRECT_HASH_PREFIX)) {
|
||||||
return {el: to.hash}
|
return {el: to.hash}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,6 +476,15 @@ const router = createRouter({
|
||||||
name: 'about',
|
name: 'about',
|
||||||
component: () => import('@/views/About.vue'),
|
component: () => import('@/views/About.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/time-tracking',
|
||||||
|
name: 'time-tracking',
|
||||||
|
component: () => import('@/views/time-tracking/TimeTracking.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresTimeTracking: true,
|
||||||
|
title: 'timeTracking.title',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
component: () => import('@/views/admin/AdminShell.vue'),
|
component: () => import('@/views/admin/AdminShell.vue'),
|
||||||
|
|
@ -503,10 +514,22 @@ const router = createRouter({
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function getAuthForRoute(to: RouteLocation, authStore) {
|
export async function getAuthForRoute(to: RouteLocation, authStore) {
|
||||||
|
// vue-router already decoded to.hash once, so slicing off the prefix yields the original
|
||||||
|
// fullPath (e.g. /oauth/authorize?...) losslessly — no extra decodeURIComponent needed.
|
||||||
|
const redirectDest = to.name === 'user.login' && to.hash.startsWith(REDIRECT_HASH_PREFIX)
|
||||||
|
? to.hash.slice(REDIRECT_HASH_PREFIX.length)
|
||||||
|
: ''
|
||||||
|
|
||||||
if (authStore.authUser || authStore.authLinkShare) {
|
if (authStore.authUser || authStore.authLinkShare) {
|
||||||
|
// An already-signed-in browser that opens a copied /login#redirect=<oauth.authorize> URL
|
||||||
|
// must run the OAuth flow with its existing session instead of short-circuiting to home.
|
||||||
|
// The destination has no redirect hash, so the second guard pass just early-returns (#2654).
|
||||||
|
if (redirectDest) {
|
||||||
|
return redirectDest
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if password reset token is in query params
|
// Check if password reset token is in query params
|
||||||
const resetToken = to.query.userPasswordReset as string | undefined
|
const resetToken = to.query.userPasswordReset as string | undefined
|
||||||
|
|
||||||
|
|
@ -530,15 +553,35 @@ export async function getAuthForRoute(to: RouteLocation, authStore) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the destination in the address bar (not just per-browser localStorage) so a native
|
||||||
|
// client's /oauth/authorize URL stays copyable into another browser. Hash, not query, so the
|
||||||
|
// embedded OAuth params never reach access logs (#2654). Pass fullPath raw: vue-router encodes
|
||||||
|
// the hash itself, so an extra encodeURIComponent here would be double-encoded in the URL.
|
||||||
|
if (to.name === 'oauth.authorize') {
|
||||||
|
return {
|
||||||
|
name: 'user.login',
|
||||||
|
hash: REDIRECT_HASH_PREFIX + to.fullPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fold the hash destination into localStorage: it's the only bridge that survives the
|
||||||
|
// external OIDC round-trip out of the SPA, so redirectIfSaved() works after any auth method.
|
||||||
|
// vue-router already decoded to.hash once, so it equals the fullPath we wrote above as-is.
|
||||||
|
if (to.hash.startsWith(REDIRECT_HASH_PREFIX)) {
|
||||||
|
const destination = to.hash.slice(REDIRECT_HASH_PREFIX.length)
|
||||||
|
const resolved = router.resolve(destination)
|
||||||
|
saveLastVisited(resolved.name as string, resolved.params, resolved.query)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the route the user wants to go to is a route which needs authentication. We use this to
|
// Check if the route the user wants to go to is a route which needs authentication. We use this to
|
||||||
// redirect the user after successful login.
|
// redirect the user after successful login.
|
||||||
const isValidUserAppRoute = !AUTH_ROUTE_NAMES.has(to.name as string) &&
|
const isValidUserAppRoute = !AUTH_ROUTE_NAMES.has(to.name as string) &&
|
||||||
localStorage.getItem('emailConfirmToken') === null
|
localStorage.getItem('emailConfirmToken') === null
|
||||||
|
|
||||||
if (isValidUserAppRoute) {
|
if (isValidUserAppRoute) {
|
||||||
saveLastVisited(to.name as string, to.params, to.query)
|
saveLastVisited(to.name as string, to.params, to.query)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isValidUserAppRoute) {
|
if (isValidUserAppRoute) {
|
||||||
return {name: 'user.login'}
|
return {name: 'user.login'}
|
||||||
}
|
}
|
||||||
|
|
@ -560,7 +603,7 @@ router.beforeEach(async (to, from) => {
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
await baseStore.appReady
|
await baseStore.appReady
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const featureOn = configStore.isProFeatureEnabled('admin_panel')
|
const featureOn = configStore.isProFeatureEnabled(PRO_FEATURE.ADMIN_PANEL)
|
||||||
// isAdmin comes from /user, not the JWT; force-fetch in case checkAuth() was debounced.
|
// isAdmin comes from /user, not the JWT; force-fetch in case checkAuth() was debounced.
|
||||||
if (authStore.info?.isAdmin === undefined) {
|
if (authStore.info?.isAdmin === undefined) {
|
||||||
await authStore.refreshUserInfo()
|
await authStore.refreshUserInfo()
|
||||||
|
|
@ -571,6 +614,15 @@ router.beforeEach(async (to, from) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (to.meta?.requiresTimeTracking) {
|
||||||
|
const baseStore = useBaseStore()
|
||||||
|
await baseStore.appReady
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
if (!configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING)) {
|
||||||
|
return {name: 'not-found'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if(from.hash && from.hash.startsWith(LINK_SHARE_HASH_PREFIX)) {
|
if(from.hash && from.hash.startsWith(LINK_SHARE_HASH_PREFIX)) {
|
||||||
to.hash = from.hash
|
to.hash = from.hash
|
||||||
}
|
}
|
||||||
|
|
@ -587,12 +639,25 @@ router.beforeEach(async (to, from) => {
|
||||||
|
|
||||||
const newRoute = await getAuthForRoute(to, authStore)
|
const newRoute = await getAuthForRoute(to, authStore)
|
||||||
if(newRoute) {
|
if(newRoute) {
|
||||||
|
// A string target (the decoded redirect destination for an authed browser) already
|
||||||
|
// carries its own query/path and no redirect hash, so navigate to it verbatim — don't
|
||||||
|
// re-attach to.hash or it would re-enter the redirect loop.
|
||||||
|
if (typeof newRoute === 'string') {
|
||||||
|
return newRoute
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...newRoute,
|
|
||||||
hash: to.hash,
|
hash: to.hash,
|
||||||
|
...newRoute,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// to.fullPath keeps the redirect hash url-encoded while to.hash is decoded, so the endsWith
|
||||||
|
// check below never matches and would re-append the hash forever. The hash is already on the
|
||||||
|
// URL here, so skip the re-attach (#2654).
|
||||||
|
if (to.hash.startsWith(REDIRECT_HASH_PREFIX)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if(!to.fullPath.endsWith(to.hash)) {
|
if(!to.fullPath.endsWith(to.hash)) {
|
||||||
return to.fullPath + to.hash
|
return to.fullPath + to.hash
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import {describe, it, expect} from 'vitest'
|
||||||
|
|
||||||
|
import {parseTimeEntry} from './timeEntry'
|
||||||
|
|
||||||
|
describe('parseTimeEntry', () => {
|
||||||
|
it('maps snake_case keys and coerces dates', () => {
|
||||||
|
const e = parseTimeEntry({
|
||||||
|
id: 1,
|
||||||
|
user_id: 2,
|
||||||
|
task_id: 3,
|
||||||
|
project_id: 0,
|
||||||
|
start_time: '2020-01-01T09:00:00Z',
|
||||||
|
end_time: '2020-01-01T10:00:00Z',
|
||||||
|
comment: 'work',
|
||||||
|
})
|
||||||
|
expect(e.userId).toBe(2)
|
||||||
|
expect(e.taskId).toBe(3)
|
||||||
|
expect(e.comment).toBe('work')
|
||||||
|
expect(e.startTime).toBeInstanceOf(Date)
|
||||||
|
expect(e.endTime).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a null end time as a running timer', () => {
|
||||||
|
const e = parseTimeEntry({
|
||||||
|
id: 1,
|
||||||
|
user_id: 1,
|
||||||
|
task_id: 1,
|
||||||
|
start_time: '2020-01-01T09:00:00Z',
|
||||||
|
end_time: null,
|
||||||
|
})
|
||||||
|
expect(e.endTime).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import {AuthenticatedHTTPFactory, getApiBaseUrl} from '@/helpers/fetcher'
|
||||||
|
import {objectToCamelCase, objectToSnakeCase} from '@/helpers/case'
|
||||||
|
|
||||||
|
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||||
|
|
||||||
|
// Time tracking is the first frontend feature on /api/v2, while the shared
|
||||||
|
// AuthenticatedHTTPFactory pins baseURL to /api/v1. We hand axios absolute v2
|
||||||
|
// URLs to bypass that. Bespoke and intentionally a bit dirty — to be folded
|
||||||
|
// into the proper service layer once the frontend moves fully onto v2.
|
||||||
|
function v2Url(path: string): string {
|
||||||
|
const v2Base = getApiBaseUrl().replace(/\/api\/v1\/$/, '/api/v2/')
|
||||||
|
return new URL(v2Base + path, window.location.origin).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTimeEntry(raw: Record<string, unknown>): ITimeEntry {
|
||||||
|
const e = objectToCamelCase(raw)
|
||||||
|
const end = e.endTime as string | null | undefined
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
userId: e.userId,
|
||||||
|
taskId: e.taskId ?? 0,
|
||||||
|
projectId: e.projectId ?? 0,
|
||||||
|
startTime: new Date(e.startTime),
|
||||||
|
// null end_time = a running timer.
|
||||||
|
endTime: end ? new Date(end) : null,
|
||||||
|
comment: e.comment ?? '',
|
||||||
|
created: new Date(e.created),
|
||||||
|
updated: new Date(e.updated),
|
||||||
|
maxPermission: e.maxPermission ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeEntryListParams {
|
||||||
|
filter?: string
|
||||||
|
filterTimezone?: string
|
||||||
|
q?: string
|
||||||
|
page?: number
|
||||||
|
perPage?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeEntryListResult {
|
||||||
|
items: ITimeEntry[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
perPage: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTimeEntryService() {
|
||||||
|
const http = AuthenticatedHTTPFactory()
|
||||||
|
|
||||||
|
async function getAll(params: TimeEntryListParams = {}): Promise<TimeEntryListResult> {
|
||||||
|
const {data} = await http.get(v2Url('time-entries'), {
|
||||||
|
params: {
|
||||||
|
filter: params.filter,
|
||||||
|
filter_timezone: params.filterTimezone,
|
||||||
|
q: params.q,
|
||||||
|
page: params.page,
|
||||||
|
per_page: params.perPage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
items: (data.items ?? []).map(parseTimeEntry),
|
||||||
|
total: data.total,
|
||||||
|
page: data.page,
|
||||||
|
perPage: data.per_page,
|
||||||
|
totalPages: data.total_pages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(entry: Partial<ITimeEntry>): Promise<ITimeEntry> {
|
||||||
|
const {data} = await http.post(v2Url('time-entries'), objectToSnakeCase(entry))
|
||||||
|
return parseTimeEntry(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(entry: Partial<ITimeEntry> & {id: number}): Promise<ITimeEntry> {
|
||||||
|
const {data} = await http.put(v2Url(`time-entries/${entry.id}`), objectToSnakeCase(entry))
|
||||||
|
return parseTimeEntry(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number): Promise<void> {
|
||||||
|
await http.delete(v2Url(`time-entries/${id}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopTimer(): Promise<ITimeEntry> {
|
||||||
|
const {data} = await http.post(v2Url('time-entries/timer/stop'))
|
||||||
|
return parseTimeEntry(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {getAll, create, update, remove, stopTimer}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import {describe, it, expect, beforeEach, vi} from 'vitest'
|
||||||
|
import {setActivePinia, createPinia} from 'pinia'
|
||||||
|
|
||||||
|
import {useAuthStore} from './auth'
|
||||||
|
import {AUTH_TYPES} from '@/modelTypes/IUser'
|
||||||
|
|
||||||
|
const {refreshTokenMock, routerPushMock, getTokenMock} = vi.hoisted(() => ({
|
||||||
|
refreshTokenMock: vi.fn(),
|
||||||
|
routerPushMock: vi.fn(),
|
||||||
|
getTokenMock: vi.fn(() => null as string | null),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/helpers/auth', () => ({
|
||||||
|
refreshToken: refreshTokenMock,
|
||||||
|
getToken: getTokenMock,
|
||||||
|
saveToken: vi.fn(),
|
||||||
|
removeToken: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/router', () => ({
|
||||||
|
default: {push: routerPushMock},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useWebSocket', () => ({
|
||||||
|
useWebSocket: () => ({disconnect: vi.fn(), connect: vi.fn()}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function fakeHttp() {
|
||||||
|
return {
|
||||||
|
post: vi.fn().mockResolvedValue({data: {}}),
|
||||||
|
get: vi.fn().mockResolvedValue({data: {}}),
|
||||||
|
request: vi.fn().mockResolvedValue({data: {}}),
|
||||||
|
interceptors: {
|
||||||
|
request: {use: vi.fn()},
|
||||||
|
response: {use: vi.fn()},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@/helpers/fetcher', () => ({
|
||||||
|
HTTPFactory: () => fakeHttp(),
|
||||||
|
AuthenticatedHTTPFactory: () => fakeHttp(),
|
||||||
|
getApiBaseUrl: () => 'http://localhost/api/v1/',
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/helpers/redirectToProvider', () => ({
|
||||||
|
getRedirectUrlFromCurrentFrontendPath: vi.fn(),
|
||||||
|
redirectToProvider: vi.fn(),
|
||||||
|
redirectToProviderOnLogout: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// A refresh failure that looks like a real network/HTTP error so renewToken's
|
||||||
|
// "is this a genuine logout?" check (it inspects the error cause's status) fires.
|
||||||
|
function refreshError() {
|
||||||
|
return new Error('Error renewing token: ', {
|
||||||
|
cause: {response: {status: 401}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A JWT carrying a not-yet-expired user session, so the checkAuth() call that
|
||||||
|
// renewToken() runs after a successful refresh treats the session as live.
|
||||||
|
function freshUserJwt() {
|
||||||
|
const payload = {
|
||||||
|
id: 1,
|
||||||
|
type: AUTH_TYPES.USER,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
}
|
||||||
|
const encoded = btoa(JSON.stringify(payload))
|
||||||
|
return `header.${encoded}.signature`
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('auth store renewToken retry (issue #2863)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
refreshTokenMock.mockReset()
|
||||||
|
routerPushMock.mockReset()
|
||||||
|
getTokenMock.mockReset().mockReturnValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
function setupExpiredUserSession(store: ReturnType<typeof useAuthStore>) {
|
||||||
|
store.setAuthenticated(true)
|
||||||
|
// Expired exp so renewToken treats a refresh failure as a real logout.
|
||||||
|
store.setUser({
|
||||||
|
id: 1,
|
||||||
|
type: AUTH_TYPES.USER,
|
||||||
|
exp: Math.floor(Date.now() / 1000) - 60,
|
||||||
|
} as never, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('does NOT log out when the first refresh fails but the retry succeeds', async () => {
|
||||||
|
const store = useAuthStore()
|
||||||
|
setupExpiredUserSession(store)
|
||||||
|
|
||||||
|
// The retry "succeeds" only if it actually leaves a usable token behind:
|
||||||
|
// renewToken() runs checkAuth() afterwards, which reads getToken(). Start
|
||||||
|
// with no token, then hand back a fresh JWT once the refresh resolves.
|
||||||
|
getTokenMock.mockReturnValue(null)
|
||||||
|
refreshTokenMock
|
||||||
|
.mockRejectedValueOnce(refreshError())
|
||||||
|
.mockImplementationOnce(async () => {
|
||||||
|
getTokenMock.mockReturnValue(freshUserJwt())
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.renewToken()
|
||||||
|
|
||||||
|
// Two refresh attempts: the initial one and the single retry.
|
||||||
|
expect(refreshTokenMock).toHaveBeenCalledTimes(2)
|
||||||
|
// The retry recovered the session: the user is still authenticated...
|
||||||
|
expect(store.authenticated).toBe(true)
|
||||||
|
// ...and was not bounced to login.
|
||||||
|
expect(routerPushMock).not.toHaveBeenCalledWith({name: 'user.login'})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs out when BOTH the refresh and its retry fail', async () => {
|
||||||
|
const store = useAuthStore()
|
||||||
|
setupExpiredUserSession(store)
|
||||||
|
|
||||||
|
refreshTokenMock
|
||||||
|
.mockRejectedValueOnce(refreshError())
|
||||||
|
.mockRejectedValueOnce(refreshError())
|
||||||
|
|
||||||
|
await store.renewToken()
|
||||||
|
|
||||||
|
expect(refreshTokenMock).toHaveBeenCalledTimes(2)
|
||||||
|
expect(routerPushMock).toHaveBeenCalledWith({name: 'user.login'})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries exactly once (no infinite loop) when the session is genuinely dead', async () => {
|
||||||
|
const store = useAuthStore()
|
||||||
|
setupExpiredUserSession(store)
|
||||||
|
|
||||||
|
refreshTokenMock.mockRejectedValue(refreshError())
|
||||||
|
|
||||||
|
await store.renewToken()
|
||||||
|
|
||||||
|
// Initial attempt + exactly one retry — never more.
|
||||||
|
expect(refreshTokenMock).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -56,6 +56,17 @@ function redirectToSpecifiedProvider() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A race-loser's refresh fails but the rotated cookie is already valid, so a
|
||||||
|
// second attempt succeeds — recovering what would otherwise be a spurious
|
||||||
|
// logout. Exactly one retry: a genuinely dead session still logs out, no loop.
|
||||||
|
async function refreshTokenWithRetry(persist: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
await refreshToken(persist)
|
||||||
|
} catch {
|
||||||
|
await refreshToken(persist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getLoggedInVia(): string | null {
|
function getLoggedInVia(): string | null {
|
||||||
return localStorage.getItem('loggedInViaProvider')
|
return localStorage.getItem('loggedInViaProvider')
|
||||||
}
|
}
|
||||||
|
|
@ -354,7 +365,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
// refresh before giving up. This lets users who reopen the app
|
// refresh before giving up. This lets users who reopen the app
|
||||||
// after the short JWT TTL seamlessly resume their session.
|
// after the short JWT TTL seamlessly resume their session.
|
||||||
try {
|
try {
|
||||||
await refreshToken(true)
|
await refreshTokenWithRetry(true)
|
||||||
const freshJwt = getToken()
|
const freshJwt = getToken()
|
||||||
if (freshJwt) {
|
if (freshJwt) {
|
||||||
const b64 = freshJwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
const b64 = freshJwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
|
@ -514,7 +525,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
saveToken(response.data.token, false)
|
saveToken(response.data.token, false)
|
||||||
} else {
|
} else {
|
||||||
// User sessions renew via the refresh-token cookie.
|
// User sessions renew via the refresh-token cookie.
|
||||||
await refreshToken(true)
|
await refreshTokenWithRetry(true)
|
||||||
}
|
}
|
||||||
await checkAuth()
|
await checkAuth()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -535,9 +546,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
|
|
||||||
// Revoke the server session so the refresh token can't be reused.
|
// Revoke the server session so the refresh token can't be reused.
|
||||||
// Best-effort: if the network call fails, still clean up locally.
|
// Best-effort: if the network call fails, still clean up locally.
|
||||||
|
let oidcLogoutUrl = ''
|
||||||
try {
|
try {
|
||||||
const HTTP = AuthenticatedHTTPFactory()
|
const HTTP = AuthenticatedHTTPFactory()
|
||||||
await HTTP.post('user/logout')
|
const {data} = await HTTP.post('user/logout')
|
||||||
|
oidcLogoutUrl = data?.oidc_logout_url ?? ''
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// Ignore — session will expire naturally
|
// Ignore — session will expire naturally
|
||||||
}
|
}
|
||||||
|
|
@ -549,7 +562,12 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
await router.push({name: 'user.login'})
|
await router.push({name: 'user.login'})
|
||||||
await checkAuth()
|
await checkAuth()
|
||||||
|
|
||||||
// if configured, redirect to OIDC Provider on logout
|
// Redirect to the OIDC provider to end its session too. Prefer the
|
||||||
|
// server-built RP-Initiated Logout URL, falling back to the static one.
|
||||||
|
if (oidcLogoutUrl) {
|
||||||
|
window.location.href = oidcLogoutUrl
|
||||||
|
return
|
||||||
|
}
|
||||||
const fullProvider: IProvider|undefined = configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === loggedInVia)
|
const fullProvider: IProvider|undefined = configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === loggedInVia)
|
||||||
if (fullProvider) {
|
if (fullProvider) {
|
||||||
redirectToProviderOnLogout(fullProvider)
|
redirectToProviderOnLogout(fullProvider)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {objectToCamelCase} from '@/helpers/case'
|
||||||
|
|
||||||
import type {IProvider} from '@/types/IProvider'
|
import type {IProvider} from '@/types/IProvider'
|
||||||
import type {MIGRATORS} from '@/views/migrate/migrators'
|
import type {MIGRATORS} from '@/views/migrate/migrators'
|
||||||
|
import type {ProFeature} from '@/constants/proFeatures'
|
||||||
import {InvalidApiUrlProvidedError} from '@/helpers/checkAndSetApiUrl'
|
import {InvalidApiUrlProvidedError} from '@/helpers/checkAndSetApiUrl'
|
||||||
|
|
||||||
export interface ConfigState {
|
export interface ConfigState {
|
||||||
|
|
@ -46,6 +47,7 @@ export interface ConfigState {
|
||||||
publicTeamsEnabled: boolean,
|
publicTeamsEnabled: boolean,
|
||||||
allowIconChanges: boolean,
|
allowIconChanges: boolean,
|
||||||
enabledProFeatures: string[],
|
enabledProFeatures: string[],
|
||||||
|
concurrentWrites: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useConfigStore = defineStore('config', () => {
|
export const useConfigStore = defineStore('config', () => {
|
||||||
|
|
@ -87,6 +89,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||||
publicTeamsEnabled: false,
|
publicTeamsEnabled: false,
|
||||||
allowIconChanges: true,
|
allowIconChanges: true,
|
||||||
enabledProFeatures: [],
|
enabledProFeatures: [],
|
||||||
|
concurrentWrites: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const migratorsEnabled = computed(() => state.availableMigrators?.length > 0)
|
const migratorsEnabled = computed(() => state.availableMigrators?.length > 0)
|
||||||
|
|
@ -104,7 +107,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||||
Object.assign(state, config)
|
Object.assign(state, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProFeatureEnabled(name: string): boolean {
|
function isProFeatureEnabled(name: ProFeature): boolean {
|
||||||
return state.enabledProFeatures?.includes(name) ?? false
|
return state.enabledProFeatures?.includes(name) ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -380,10 +380,11 @@ export function useProject(projectId: MaybeRefOrGetter<IProject['id']>) {
|
||||||
success({message: t('project.edit.success')})
|
success({message: t('project.edit.success')})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function duplicateProject(parentProjectId: IProject['id']) {
|
async function duplicateProject(parentProjectId: IProject['id'], duplicateShares: boolean = false) {
|
||||||
const projectDuplicate = new ProjectDuplicateModel({
|
const projectDuplicate = new ProjectDuplicateModel({
|
||||||
projectId: Number(toValue(projectId)),
|
projectId: Number(toValue(projectId)),
|
||||||
parentProjectId,
|
parentProjectId,
|
||||||
|
duplicateShares,
|
||||||
})
|
})
|
||||||
|
|
||||||
const duplicate = await projectDuplicateService.create(projectDuplicate)
|
const duplicate = await projectDuplicateService.create(projectDuplicate)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import {describe, expect, it} from 'vitest'
|
import {describe, expect, it} from 'vitest'
|
||||||
import {buildDefaultRemindersForQuickAdd} from './tasks'
|
import {buildDefaultRemindersForQuickAdd, runWrites} from './tasks'
|
||||||
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
|
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
|
||||||
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
|
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
|
||||||
|
|
||||||
|
|
@ -42,3 +42,39 @@ describe('buildDefaultRemindersForQuickAdd', () => {
|
||||||
expect(result[0].relativeTo).toBe(REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE)
|
expect(result[0].relativeTo).toBe(REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('runWrites', () => {
|
||||||
|
function deferredWrite() {
|
||||||
|
const inFlight: string[] = []
|
||||||
|
let maxConcurrent = 0
|
||||||
|
const completed: string[] = []
|
||||||
|
const write = async (item: string) => {
|
||||||
|
inFlight.push(item)
|
||||||
|
maxConcurrent = Math.max(maxConcurrent, inFlight.length)
|
||||||
|
await Promise.resolve()
|
||||||
|
inFlight.splice(inFlight.indexOf(item), 1)
|
||||||
|
completed.push(item)
|
||||||
|
}
|
||||||
|
return {write, completed, getMaxConcurrent: () => maxConcurrent}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('runs all writes in parallel when concurrent', async () => {
|
||||||
|
const {write, completed, getMaxConcurrent} = deferredWrite()
|
||||||
|
await runWrites(['a', 'b', 'c'], write, true)
|
||||||
|
expect(completed).toHaveLength(3)
|
||||||
|
expect(getMaxConcurrent()).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('runs writes one at a time when not concurrent', async () => {
|
||||||
|
const {write, completed, getMaxConcurrent} = deferredWrite()
|
||||||
|
await runWrites(['a', 'b', 'c'], write, false)
|
||||||
|
expect(completed).toEqual(['a', 'b', 'c'])
|
||||||
|
expect(getMaxConcurrent()).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing for an empty list', async () => {
|
||||||
|
const {write, completed} = deferredWrite()
|
||||||
|
await runWrites([], write, false)
|
||||||
|
expect(completed).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import type {IProject} from '@/modelTypes/IProject'
|
||||||
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
|
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
|
||||||
|
|
||||||
import {setModuleLoading} from '@/stores/helper'
|
import {setModuleLoading} from '@/stores/helper'
|
||||||
|
import {useConfigStore} from '@/stores/config'
|
||||||
import {useLabelStore} from '@/stores/labels'
|
import {useLabelStore} from '@/stores/labels'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useKanbanStore} from '@/stores/kanban'
|
import {useKanbanStore} from '@/stores/kanban'
|
||||||
|
|
@ -59,6 +60,22 @@ export function buildDefaultRemindersForQuickAdd(
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runWrites applies a write to each item. SQLite deadlocks on concurrent writes
|
||||||
|
// (read-then-write upgrade conflict), so callers pass concurrent=false to serialize.
|
||||||
|
export async function runWrites<T>(
|
||||||
|
items: readonly T[],
|
||||||
|
write: (item: T) => Promise<unknown>,
|
||||||
|
concurrent: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
if (concurrent) {
|
||||||
|
await Promise.all(items.map(item => write(item)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const item of items) {
|
||||||
|
await write(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// IDEA: maybe use a small fuzzy search here to prevent errors
|
// IDEA: maybe use a small fuzzy search here to prevent errors
|
||||||
function findPropertyByValue(object, key, value, fuzzy = false) {
|
function findPropertyByValue(object, key, value, fuzzy = false) {
|
||||||
return Object.values(object).find(l => {
|
return Object.values(object).find(l => {
|
||||||
|
|
@ -131,6 +148,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
const labelStore = useLabelStore()
|
const labelStore = useLabelStore()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
const tasks = ref<{ [id: ITask['id']]: ITask }>({}) // TODO: or is this ITask[]
|
const tasks = ref<{ [id: ITask['id']]: ITask }>({}) // TODO: or is this ITask[]
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
@ -395,10 +413,7 @@ export const useTaskStore = defineStore('task', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const labels = await ensureLabelsExist(parsedLabels)
|
const labels = await ensureLabelsExist(parsedLabels)
|
||||||
const labelAddsToWaitFor = labels.map(async l => addLabelToTask(task, l))
|
await runWrites(labels, l => addLabelToTask(task, l), configStore.concurrentWrites)
|
||||||
|
|
||||||
// This waits until all labels are created and added to the task
|
|
||||||
await Promise.all(labelAddsToWaitFor)
|
|
||||||
return task
|
return task
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import {describe, it, expect, beforeEach, vi} from 'vitest'
|
||||||
|
import {setActivePinia, createPinia} from 'pinia'
|
||||||
|
|
||||||
|
import {useTimeTrackingStore} from './timeTracking'
|
||||||
|
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||||
|
|
||||||
|
const {getAllMock, removeMock, authInfo} = vi.hoisted(() => ({
|
||||||
|
getAllMock: vi.fn(),
|
||||||
|
removeMock: vi.fn(),
|
||||||
|
authInfo: {value: {id: 7} as {id: number} | null},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/services/timeEntry', async importOriginal => {
|
||||||
|
const actual = await importOriginal<typeof import('@/services/timeEntry')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTimeEntryService: () => ({
|
||||||
|
getAll: getAllMock,
|
||||||
|
remove: removeMock,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/stores/auth', () => ({
|
||||||
|
useAuthStore: () => ({
|
||||||
|
info: authInfo.value,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function entry(id: number, endTime: Date | null): ITimeEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
userId: 1,
|
||||||
|
taskId: 1,
|
||||||
|
projectId: 0,
|
||||||
|
startTime: new Date(),
|
||||||
|
endTime,
|
||||||
|
comment: '',
|
||||||
|
created: new Date(),
|
||||||
|
updated: new Date(),
|
||||||
|
maxPermission: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('timeTracking store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
getAllMock.mockReset()
|
||||||
|
removeMock.mockReset()
|
||||||
|
authInfo.value = {id: 7}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a running entry becomes the active timer', () => {
|
||||||
|
const store = useTimeTrackingStore()
|
||||||
|
store.applyTimerEvent(entry(4, null))
|
||||||
|
expect(store.activeTimer?.id).toBe(4)
|
||||||
|
expect(store.hasActiveTimer).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a stopped entry clears the matching active timer', () => {
|
||||||
|
const store = useTimeTrackingStore()
|
||||||
|
store.applyTimerEvent(entry(4, null))
|
||||||
|
store.applyTimerEvent(entry(4, new Date()))
|
||||||
|
expect(store.activeTimer).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a stop for a different timer leaves the active one alone', () => {
|
||||||
|
const store = useTimeTrackingStore()
|
||||||
|
store.applyTimerEvent(entry(4, null))
|
||||||
|
store.applyTimerEvent(entry(5, new Date()))
|
||||||
|
expect(store.activeTimer?.id).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('patches a stopped entry in the loaded list', () => {
|
||||||
|
const store = useTimeTrackingStore()
|
||||||
|
store.browsedEntries = [entry(4, null), entry(5, null)]
|
||||||
|
const stopped = entry(4, new Date('2026-01-01T10:00:00Z'))
|
||||||
|
store.applyTimerEvent(stopped)
|
||||||
|
expect(store.browsedEntries.find((e: ITimeEntry) => e.id === 4)?.endTime).toEqual(stopped.endTime)
|
||||||
|
expect(store.browsedEntries).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not insert an unknown entry into the loaded list', () => {
|
||||||
|
const store = useTimeTrackingStore()
|
||||||
|
store.browsedEntries = [entry(4, null)]
|
||||||
|
store.applyTimerEvent(entry(9, new Date()))
|
||||||
|
expect(store.browsedEntries).toHaveLength(1)
|
||||||
|
expect(store.browsedEntries.find((e: ITimeEntry) => e.id === 9)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hydrates the active timer scoped to the current user', async () => {
|
||||||
|
getAllMock.mockResolvedValue({items: [entry(4, null)]})
|
||||||
|
|
||||||
|
const store = useTimeTrackingStore()
|
||||||
|
await store.hydrateActiveTimer()
|
||||||
|
|
||||||
|
expect(getAllMock).toHaveBeenCalledWith({
|
||||||
|
filter: 'user_id = 7 && end_time = null',
|
||||||
|
perPage: 1,
|
||||||
|
})
|
||||||
|
expect(store.activeTimer?.id).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the active timer when deleting the running entry', async () => {
|
||||||
|
removeMock.mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const store = useTimeTrackingStore()
|
||||||
|
store.browsedEntries = [entry(4, null), entry(5, new Date())]
|
||||||
|
store.applyTimerEvent(entry(4, null))
|
||||||
|
|
||||||
|
await store.removeEntry(4)
|
||||||
|
|
||||||
|
expect(removeMock).toHaveBeenCalledWith(4)
|
||||||
|
expect(store.browsedEntries.map((e: ITimeEntry) => e.id)).toEqual([5])
|
||||||
|
expect(store.activeTimer).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applyTimerDeletion drops the entry and clears the matching active timer', () => {
|
||||||
|
const store = useTimeTrackingStore()
|
||||||
|
store.browsedEntries = [entry(4, null), entry(5, new Date())]
|
||||||
|
store.applyTimerEvent(entry(4, null))
|
||||||
|
|
||||||
|
store.applyTimerDeletion(4)
|
||||||
|
|
||||||
|
expect(store.browsedEntries.map((e: ITimeEntry) => e.id)).toEqual([5])
|
||||||
|
expect(store.activeTimer).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applyTimerDeletion of another entry leaves the active timer alone', () => {
|
||||||
|
const store = useTimeTrackingStore()
|
||||||
|
store.browsedEntries = [entry(4, null), entry(5, new Date())]
|
||||||
|
store.applyTimerEvent(entry(4, null))
|
||||||
|
|
||||||
|
store.applyTimerDeletion(5)
|
||||||
|
|
||||||
|
expect(store.browsedEntries.map((e: ITimeEntry) => e.id)).toEqual([4])
|
||||||
|
expect(store.activeTimer?.id).toBe(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import {ref, computed} from 'vue'
|
||||||
|
import {acceptHMRUpdate, defineStore} from 'pinia'
|
||||||
|
|
||||||
|
import {useWebSocket} from '@/composables/useWebSocket'
|
||||||
|
import {useTimeEntryService, parseTimeEntry} from '@/services/timeEntry'
|
||||||
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
|
||||||
|
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||||
|
|
||||||
|
export const useTimeTrackingStore = defineStore('timeTracking', () => {
|
||||||
|
const activeTimer = ref<ITimeEntry | null>(null)
|
||||||
|
const browsedEntries = ref<ITimeEntry[]>([])
|
||||||
|
|
||||||
|
const hasActiveTimer = computed(() => activeTimer.value !== null)
|
||||||
|
|
||||||
|
async function browseEntries(filter: string) {
|
||||||
|
const {items} = await useTimeEntryService().getAll({
|
||||||
|
filter,
|
||||||
|
filterTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
perPage: 250,
|
||||||
|
})
|
||||||
|
browsedEntries.value = items
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop a deleted entry from the list and clear the active timer if it was it.
|
||||||
|
// Shared by the local delete and the cross-tab WebSocket "timer.deleted".
|
||||||
|
function applyTimerDeletion(id: number) {
|
||||||
|
browsedEntries.value = browsedEntries.value.filter(entry => entry.id !== id)
|
||||||
|
if (activeTimer.value?.id === id) {
|
||||||
|
activeTimer.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeEntry(id: number) {
|
||||||
|
await useTimeEntryService().remove(id)
|
||||||
|
applyTimerDeletion(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace an already-loaded entry in place so a stop (or any update) is
|
||||||
|
// reflected without a refetch. Never inserts — an event for an entry that
|
||||||
|
// isn't in the current filter shouldn't appear in the list.
|
||||||
|
function patchInList(entry: ITimeEntry) {
|
||||||
|
const index = browsedEntries.value.findIndex(existing => existing.id === entry.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
browsedEntries.value.splice(index, 1, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile the active timer from a timer event (WebSocket) or a local
|
||||||
|
// action: an entry with an end time is a stop — clear it if it's the one we
|
||||||
|
// track; otherwise it is the running timer.
|
||||||
|
function applyTimerEvent(entry: ITimeEntry) {
|
||||||
|
patchInList(entry)
|
||||||
|
if (entry.endTime !== null) {
|
||||||
|
if (activeTimer.value?.id === entry.id) {
|
||||||
|
activeTimer.value = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeTimer.value = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source of truth on (re)connect: the caller's own running timer, if any.
|
||||||
|
async function hydrateActiveTimer() {
|
||||||
|
const userId = useAuthStore().info?.id
|
||||||
|
if (userId === undefined) {
|
||||||
|
activeTimer.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const {items} = await useTimeEntryService().getAll({
|
||||||
|
filter: `user_id = ${userId} && end_time = null`,
|
||||||
|
perPage: 1,
|
||||||
|
})
|
||||||
|
activeTimer.value = items[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create any entry (manual, with an end time, or a running timer when end is
|
||||||
|
// omitted) and reconcile the active timer from the result.
|
||||||
|
async function createEntry(payload: Partial<ITimeEntry>) {
|
||||||
|
const entry = await useTimeEntryService().create(payload)
|
||||||
|
applyTimerEvent(entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateEntry(payload: Partial<ITimeEntry> & {id: number}) {
|
||||||
|
const entry = await useTimeEntryService().update(payload)
|
||||||
|
applyTimerEvent(entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopTimer() {
|
||||||
|
const entry = await useTimeEntryService().stopTimer()
|
||||||
|
applyTimerEvent(entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
let unsubscribers: Array<() => void> = []
|
||||||
|
function subscribeToTimerEvents() {
|
||||||
|
const {subscribe} = useWebSocket()
|
||||||
|
// Ignore messages without a payload (e.g. subscribe acknowledgements).
|
||||||
|
const onEvent = (msg: {data?: unknown}) => {
|
||||||
|
if (msg.data == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyTimerEvent(parseTimeEntry(msg.data as Record<string, unknown>))
|
||||||
|
}
|
||||||
|
const onDelete = (msg: {data?: unknown}) => {
|
||||||
|
if (msg.data == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyTimerDeletion(parseTimeEntry(msg.data as Record<string, unknown>).id)
|
||||||
|
}
|
||||||
|
unsubscribers.push(subscribe('timer.created', onEvent))
|
||||||
|
unsubscribers.push(subscribe('timer.updated', onEvent))
|
||||||
|
unsubscribers.push(subscribe('timer.deleted', onDelete))
|
||||||
|
}
|
||||||
|
function unsubscribeFromTimerEvents() {
|
||||||
|
unsubscribers.forEach(unsubscribe => unsubscribe())
|
||||||
|
unsubscribers = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTimer,
|
||||||
|
browsedEntries,
|
||||||
|
hasActiveTimer,
|
||||||
|
applyTimerEvent,
|
||||||
|
applyTimerDeletion,
|
||||||
|
hydrateActiveTimer,
|
||||||
|
browseEntries,
|
||||||
|
createEntry,
|
||||||
|
updateEntry,
|
||||||
|
stopTimer,
|
||||||
|
removeEntry,
|
||||||
|
subscribeToTimerEvents,
|
||||||
|
unsubscribeFromTimerEvents,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.accept(acceptHMRUpdate(useTimeTrackingStore, import.meta.hot))
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-pulled-right {
|
.is-pulled-end {
|
||||||
float: right !important;
|
float: right !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .is-pulled-end {
|
||||||
|
float: left !important;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
>
|
>
|
||||||
<XButton
|
<XButton
|
||||||
:to="{name:'labels.create'}"
|
:to="{name:'labels.create'}"
|
||||||
class="is-pulled-right"
|
class="is-pulled-end"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
>
|
>
|
||||||
{{ $t('label.create.header') }}
|
{{ $t('label.create.header') }}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@
|
||||||
>
|
>
|
||||||
<p>{{ $t('project.duplicate.text') }}</p>
|
<p>{{ $t('project.duplicate.text') }}</p>
|
||||||
<ProjectSearch v-model="parentProject" />
|
<ProjectSearch v-model="parentProject" />
|
||||||
|
<FancyCheckbox
|
||||||
|
v-model="duplicateShares"
|
||||||
|
class="mbs-2"
|
||||||
|
>
|
||||||
|
{{ $t('project.duplicate.shares') }}
|
||||||
|
</FancyCheckbox>
|
||||||
</CreateEdit>
|
</CreateEdit>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -18,6 +24,7 @@ import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
import CreateEdit from '@/components/misc/CreateEdit.vue'
|
import CreateEdit from '@/components/misc/CreateEdit.vue'
|
||||||
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
|
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
|
||||||
|
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
||||||
|
|
||||||
import {success} from '@/message'
|
import {success} from '@/message'
|
||||||
import {useTitle} from '@/composables/useTitle'
|
import {useTitle} from '@/composables/useTitle'
|
||||||
|
|
@ -33,6 +40,7 @@ const projectStore = useProjectStore()
|
||||||
const {project, isLoading, duplicateProject} = useProject(route.params.projectId)
|
const {project, isLoading, duplicateProject} = useProject(route.params.projectId)
|
||||||
|
|
||||||
const parentProject = ref<IProject | null>(null)
|
const parentProject = ref<IProject | null>(null)
|
||||||
|
const duplicateShares = ref(true)
|
||||||
const isDuplicating = ref(false)
|
const isDuplicating = ref(false)
|
||||||
|
|
||||||
const loadingModel = computed({
|
const loadingModel = computed({
|
||||||
|
|
@ -53,7 +61,7 @@ async function duplicate() {
|
||||||
isDuplicating.value = true
|
isDuplicating.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await duplicateProject(parentProject.value?.id ?? 0)
|
await duplicateProject(parentProject.value?.id ?? 0, duplicateShares.value)
|
||||||
success({message: t('project.duplicate.success')})
|
success({message: t('project.duplicate.success')})
|
||||||
} finally {
|
} finally {
|
||||||
isDuplicating.value = false
|
isDuplicating.value = false
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,15 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Time Tracking -->
|
||||||
|
<div
|
||||||
|
v-if="timeTrackingEnabled && activeFields.timeTracking"
|
||||||
|
:ref="e => setFieldRef('timeTracking', e)"
|
||||||
|
class="content time-tracking"
|
||||||
|
>
|
||||||
|
<TaskTimeTracking :task-id="task.id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Related Tasks -->
|
<!-- Related Tasks -->
|
||||||
<div
|
<div
|
||||||
v-if="activeFields.relatedTasks"
|
v-if="activeFields.relatedTasks"
|
||||||
|
|
@ -537,6 +546,16 @@
|
||||||
|
|
||||||
<span class="action-heading">{{ $t('task.detail.dateAndTime') }}</span>
|
<span class="action-heading">{{ $t('task.detail.dateAndTime') }}</span>
|
||||||
|
|
||||||
|
<XButton
|
||||||
|
v-if="timeTrackingEnabled"
|
||||||
|
v-cy="'taskTrackTimeAction'"
|
||||||
|
variant="secondary"
|
||||||
|
:icon="['far', 'clock']"
|
||||||
|
@click="setFieldActive('timeTracking')"
|
||||||
|
>
|
||||||
|
{{ $t('task.detail.actions.timeTracking') }}
|
||||||
|
</XButton>
|
||||||
|
|
||||||
<XButton
|
<XButton
|
||||||
v-shortcut="'KeyD'"
|
v-shortcut="'KeyD'"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
@ -643,11 +662,13 @@ import type {IProject} from '@/modelTypes/IProject'
|
||||||
|
|
||||||
import {PRIORITIES, type Priority} from '@/constants/priorities'
|
import {PRIORITIES, type Priority} from '@/constants/priorities'
|
||||||
import {PERMISSIONS} from '@/constants/permissions'
|
import {PERMISSIONS} from '@/constants/permissions'
|
||||||
|
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||||
|
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
// partials
|
// partials
|
||||||
import Attachments from '@/components/tasks/partials/Attachments.vue'
|
import Attachments from '@/components/tasks/partials/Attachments.vue'
|
||||||
|
import TaskTimeTracking from '@/components/time-tracking/TaskTimeTracking.vue'
|
||||||
import ChecklistSummary from '@/components/tasks/partials/ChecklistSummary.vue'
|
import ChecklistSummary from '@/components/tasks/partials/ChecklistSummary.vue'
|
||||||
import ColorPicker from '@/components/input/ColorPicker.vue'
|
import ColorPicker from '@/components/input/ColorPicker.vue'
|
||||||
import Comments from '@/components/tasks/partials/Comments.vue'
|
import Comments from '@/components/tasks/partials/Comments.vue'
|
||||||
|
|
@ -682,6 +703,7 @@ import {useKanbanStore} from '@/stores/kanban'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
import {useConfigStore} from '@/stores/config'
|
||||||
|
|
||||||
import {useTitle} from '@/composables/useTitle'
|
import {useTitle} from '@/composables/useTitle'
|
||||||
import {useTaskDetailShortcuts} from '@/composables/useTaskDetailShortcuts'
|
import {useTaskDetailShortcuts} from '@/composables/useTaskDetailShortcuts'
|
||||||
|
|
@ -704,6 +726,8 @@ const {t} = useI18n({useScope: 'global'})
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
const timeTrackingEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING))
|
||||||
const kanbanStore = useKanbanStore()
|
const kanbanStore = useKanbanStore()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
|
|
@ -923,7 +947,12 @@ watch(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const loaded = await taskService.get({id}, {expand: ['reactions', 'comments', 'is_unread', 'buckets']})
|
const expand = ['reactions', 'comments', 'is_unread', 'buckets']
|
||||||
|
if (timeTrackingEnabled.value) {
|
||||||
|
// Only request the (server-computed) count when the feature is on.
|
||||||
|
expand.push('time_entries_count')
|
||||||
|
}
|
||||||
|
const loaded = await taskService.get({id}, {expand})
|
||||||
Object.assign(task.value, loaded)
|
Object.assign(task.value, loaded)
|
||||||
taskColor.value = task.value.hexColor
|
taskColor.value = task.value.hexColor
|
||||||
setActiveFields()
|
setActiveFields()
|
||||||
|
|
@ -967,6 +996,7 @@ type FieldType =
|
||||||
| 'reminders'
|
| 'reminders'
|
||||||
| 'repeatAfter'
|
| 'repeatAfter'
|
||||||
| 'startDate'
|
| 'startDate'
|
||||||
|
| 'timeTracking'
|
||||||
|
|
||||||
const activeFields: { [type in FieldType]: boolean } = reactive({
|
const activeFields: { [type in FieldType]: boolean } = reactive({
|
||||||
assignees: false,
|
assignees: false,
|
||||||
|
|
@ -982,6 +1012,7 @@ const activeFields: { [type in FieldType]: boolean } = reactive({
|
||||||
reminders: false,
|
reminders: false,
|
||||||
repeatAfter: false,
|
repeatAfter: false,
|
||||||
startDate: false,
|
startDate: false,
|
||||||
|
timeTracking: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
function setActiveFields() {
|
function setActiveFields() {
|
||||||
|
|
@ -992,6 +1023,7 @@ function setActiveFields() {
|
||||||
// Set all active fields based on values in the model
|
// Set all active fields based on values in the model
|
||||||
activeFields.assignees = task.value.assignees.length > 0
|
activeFields.assignees = task.value.assignees.length > 0
|
||||||
activeFields.attachments = task.value.attachments.length > 0
|
activeFields.attachments = task.value.attachments.length > 0
|
||||||
|
activeFields.timeTracking = (task.value.timeEntriesCount ?? 0) > 0
|
||||||
activeFields.dueDate = task.value.dueDate !== null
|
activeFields.dueDate = task.value.dueDate !== null
|
||||||
activeFields.endDate = task.value.endDate !== null
|
activeFields.endDate = task.value.endDate !== null
|
||||||
activeFields.labels = task.value.labels.length > 0
|
activeFields.labels = task.value.labels.length > 0
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
>
|
>
|
||||||
<XButton
|
<XButton
|
||||||
:to="{name:'teams.create'}"
|
:to="{name:'teams.create'}"
|
||||||
class="is-pulled-right"
|
class="is-pulled-end"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
>
|
>
|
||||||
{{ $t('team.create.title') }}
|
{{ $t('team.create.title') }}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,396 @@
|
||||||
|
<template>
|
||||||
|
<div class="time-tracking">
|
||||||
|
<div class="time-tracking__actions">
|
||||||
|
<span class="time-tracking__range">
|
||||||
|
{{ rangeLabel }}
|
||||||
|
</span>
|
||||||
|
<div class="time-tracking__buttons">
|
||||||
|
<XButton
|
||||||
|
v-cy="'addTimeEntry'"
|
||||||
|
variant="secondary"
|
||||||
|
icon="plus"
|
||||||
|
:class="{'is-active': showForm}"
|
||||||
|
@click="showForm = !showForm"
|
||||||
|
>
|
||||||
|
{{ $t('timeTracking.logTime') }}
|
||||||
|
</XButton>
|
||||||
|
<XButton
|
||||||
|
v-cy="'openTimeTrackingFilters'"
|
||||||
|
variant="secondary"
|
||||||
|
icon="filter"
|
||||||
|
:class="{'has-filters': hasFilters}"
|
||||||
|
@click="filterModalOpen = true"
|
||||||
|
>
|
||||||
|
{{ $t('filters.title') }}
|
||||||
|
</XButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
v-if="formVisible"
|
||||||
|
:title="$t(editingEntry ? 'timeTracking.editEntry' : 'timeTracking.logTime')"
|
||||||
|
>
|
||||||
|
<TimeEntryForm
|
||||||
|
:entry="editingEntry"
|
||||||
|
:recent-entries="timeTrackingStore.browsedEntries"
|
||||||
|
@saved="onSaved"
|
||||||
|
@cancel="editingEntry = null"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<TimeEntryList
|
||||||
|
:entries="timeTrackingStore.browsedEntries"
|
||||||
|
:empty-text="$t('timeTracking.list.emptyFiltered')"
|
||||||
|
@edit="editingEntry = $event"
|
||||||
|
@delete="onDelete"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
:enabled="filterModalOpen"
|
||||||
|
:overflow="true"
|
||||||
|
variant="hint-modal"
|
||||||
|
@close="filterModalOpen = false"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
class="has-overflow"
|
||||||
|
:title="$t('filters.title')"
|
||||||
|
show-close
|
||||||
|
@close="filterModalOpen = false"
|
||||||
|
>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ $t('misc.dateRange') }}</label>
|
||||||
|
<DatepickerWithRange v-model="dateRange">
|
||||||
|
<template #trigger="{toggle, buttonText}">
|
||||||
|
<XButton
|
||||||
|
variant="secondary"
|
||||||
|
:shadow="false"
|
||||||
|
@click.prevent.stop="toggle()"
|
||||||
|
>
|
||||||
|
{{ buttonText || $t('timeTracking.browse.selectRange') }}
|
||||||
|
</XButton>
|
||||||
|
</template>
|
||||||
|
</DatepickerWithRange>
|
||||||
|
</div>
|
||||||
|
<div class="filter-columns">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ $t('task.attributes.project') }}</label>
|
||||||
|
<ProjectSearch v-model="selectedProject" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ $t('timeTracking.form.task') }}</label>
|
||||||
|
<Multiselect
|
||||||
|
v-model="selectedTask"
|
||||||
|
:placeholder="$t('timeTracking.form.taskSearch')"
|
||||||
|
:loading="taskService.loading"
|
||||||
|
:search-results="foundTasks"
|
||||||
|
label="title"
|
||||||
|
@search="findTasks"
|
||||||
|
>
|
||||||
|
<template #searchResult="{option}">
|
||||||
|
{{ option.title }}
|
||||||
|
</template>
|
||||||
|
</Multiselect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ $t('misc.user') }}</label>
|
||||||
|
<Multiselect
|
||||||
|
v-model="selectedUser"
|
||||||
|
:placeholder="$t('timeTracking.browse.userSearch')"
|
||||||
|
:loading="userService.loading"
|
||||||
|
:search-results="foundUsers"
|
||||||
|
label="username"
|
||||||
|
@search="findUsers"
|
||||||
|
>
|
||||||
|
<template #searchResult="{option}">
|
||||||
|
{{ option.username }}
|
||||||
|
</template>
|
||||||
|
</Multiselect>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref, computed, shallowReactive, watch, nextTick, onMounted} from 'vue'
|
||||||
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
|
import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
|
import Modal from '@/components/misc/Modal.vue'
|
||||||
|
import Card from '@/components/misc/Card.vue'
|
||||||
|
import DatepickerWithRange from '@/components/date/DatepickerWithRange.vue'
|
||||||
|
import {DATE_RANGES} from '@/components/date/dateRanges'
|
||||||
|
import Multiselect from '@/components/input/Multiselect.vue'
|
||||||
|
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
|
||||||
|
import TimeEntryForm from '@/components/time-tracking/TimeEntryForm.vue'
|
||||||
|
import TimeEntryList from '@/components/time-tracking/TimeEntryList.vue'
|
||||||
|
|
||||||
|
import TaskService from '@/services/task'
|
||||||
|
import TaskModel from '@/models/task'
|
||||||
|
import UserService from '@/services/user'
|
||||||
|
import {useTitle} from '@/composables/useTitle'
|
||||||
|
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||||
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
|
||||||
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
import type {IUser} from '@/modelTypes/IUser'
|
||||||
|
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||||
|
|
||||||
|
const {t} = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const timeTrackingStore = useTimeTrackingStore()
|
||||||
|
const baseStore = useBaseStore()
|
||||||
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
|
useTitle(() => t('timeTracking.title'))
|
||||||
|
|
||||||
|
const showForm = ref(false)
|
||||||
|
const editingEntry = ref<ITimeEntry | null>(null)
|
||||||
|
const formVisible = computed(() => showForm.value || editingEntry.value !== null)
|
||||||
|
|
||||||
|
function onSaved() {
|
||||||
|
editingEntry.value = null
|
||||||
|
showForm.value = false
|
||||||
|
timeTrackingStore.browseEntries(filter.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDelete(id: number) {
|
||||||
|
timeTrackingStore.removeEntry(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Filter ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// DatepickerWithRange emits null for a side when the range is cleared (Custom).
|
||||||
|
const dateRange = ref<{dateFrom: Date | string | null, dateTo: Date | string | null}>({
|
||||||
|
dateFrom: 'now/d',
|
||||||
|
dateTo: 'now/d+1d',
|
||||||
|
})
|
||||||
|
const selectedProject = ref<IProject | null>(null)
|
||||||
|
const selectedTask = ref<ITask | null>(null)
|
||||||
|
const selectedUser = ref<IUser | null>(null)
|
||||||
|
const filterModalOpen = ref(false)
|
||||||
|
|
||||||
|
const hasFilters = computed(() =>
|
||||||
|
selectedProject.value !== null ||
|
||||||
|
selectedTask.value !== null ||
|
||||||
|
selectedUser.value !== null ||
|
||||||
|
dateRange.value.dateFrom !== 'now/d' ||
|
||||||
|
dateRange.value.dateTo !== 'now/d+1d',
|
||||||
|
)
|
||||||
|
|
||||||
|
// The active range as a label (the preset name when it matches, else the dates).
|
||||||
|
const rangeLabel = computed(() => {
|
||||||
|
const {dateFrom, dateTo} = dateRange.value
|
||||||
|
if (!dateFrom || !dateTo) {
|
||||||
|
return t('timeTracking.browse.selectRange')
|
||||||
|
}
|
||||||
|
const preset = Object.entries(DATE_RANGES).find(
|
||||||
|
([, range]) => dateFrom === range[0] && dateTo === range[1],
|
||||||
|
)
|
||||||
|
if (preset) {
|
||||||
|
return t(`input.datepickerRange.ranges.${preset[0]}`)
|
||||||
|
}
|
||||||
|
return t('input.datepickerRange.fromto', {from: dateValue(dateFrom), to: dateValue(dateTo)})
|
||||||
|
})
|
||||||
|
|
||||||
|
const taskService = shallowReactive(new TaskService())
|
||||||
|
const foundTasks = ref<ITask[]>([])
|
||||||
|
async function findTasks(query: string) {
|
||||||
|
if (query === '') {
|
||||||
|
foundTasks.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
foundTasks.value = await taskService.getAll({}, {s: query, sort_by: 'done'}) as ITask[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const userService = shallowReactive(new UserService())
|
||||||
|
const foundUsers = ref<IUser[]>([])
|
||||||
|
async function findUsers(query: string) {
|
||||||
|
if (query === '') {
|
||||||
|
foundUsers.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
foundUsers.value = await userService.getAll({}, {s: query}) as IUser[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datemath preset strings (now/M) pass through unchanged; a custom Date becomes
|
||||||
|
// YYYY-MM-DD — both avoid the ':' the filter grammar tokenises on.
|
||||||
|
function dateValue(value: Date | string): string {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const year = value.getFullYear()
|
||||||
|
const month = String(value.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(value.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = computed(() => {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (dateRange.value.dateFrom) {
|
||||||
|
parts.push(`start_time > ${dateValue(dateRange.value.dateFrom)}`)
|
||||||
|
}
|
||||||
|
if (dateRange.value.dateTo) {
|
||||||
|
parts.push(`start_time < ${dateValue(dateRange.value.dateTo)}`)
|
||||||
|
}
|
||||||
|
if (selectedUser.value !== null) {
|
||||||
|
parts.push(`user_id = ${selectedUser.value.id}`)
|
||||||
|
}
|
||||||
|
if (selectedTask.value !== null) {
|
||||||
|
parts.push(`task_id = ${selectedTask.value.id}`)
|
||||||
|
}
|
||||||
|
if (selectedProject.value !== null) {
|
||||||
|
parts.push(`project_id = ${selectedProject.value.id}`)
|
||||||
|
}
|
||||||
|
return parts.join(' && ')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Persist the active filter to the URL so it's shareable and survives reloads.
|
||||||
|
const filterQuery = computed(() => {
|
||||||
|
const q: Record<string, string> = {}
|
||||||
|
if (dateRange.value.dateFrom && dateRange.value.dateFrom !== 'now/d') {
|
||||||
|
q.from = dateValue(dateRange.value.dateFrom)
|
||||||
|
}
|
||||||
|
if (dateRange.value.dateTo && dateRange.value.dateTo !== 'now/d+1d') {
|
||||||
|
q.to = dateValue(dateRange.value.dateTo)
|
||||||
|
}
|
||||||
|
if (selectedProject.value !== null) {
|
||||||
|
q.project = String(selectedProject.value.id)
|
||||||
|
}
|
||||||
|
if (selectedTask.value !== null) {
|
||||||
|
q.task = String(selectedTask.value.id)
|
||||||
|
}
|
||||||
|
if (selectedUser.value !== null) {
|
||||||
|
q.user = selectedUser.value.username
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
})
|
||||||
|
|
||||||
|
const ready = ref(false)
|
||||||
|
|
||||||
|
async function restoreFromQuery() {
|
||||||
|
const q = route.query
|
||||||
|
if (typeof q.from === 'string') {
|
||||||
|
dateRange.value.dateFrom = q.from
|
||||||
|
}
|
||||||
|
if (typeof q.to === 'string') {
|
||||||
|
dateRange.value.dateTo = q.to
|
||||||
|
}
|
||||||
|
// Resolve project/task by id and the user by username up front (the project
|
||||||
|
// store may not be hydrated yet on a hard reload), so the first request
|
||||||
|
// already carries the full filter — and the modal shows the real names.
|
||||||
|
await Promise.all([
|
||||||
|
typeof q.project === 'string'
|
||||||
|
? projectStore.loadProject(Number(q.project))
|
||||||
|
.then(p => { selectedProject.value = p as IProject })
|
||||||
|
.catch(() => { /* project gone — drop the filter */ })
|
||||||
|
: Promise.resolve(),
|
||||||
|
typeof q.task === 'string'
|
||||||
|
? taskService.get(new TaskModel({id: Number(q.task)}))
|
||||||
|
.then(t => { selectedTask.value = t as ITask })
|
||||||
|
.catch(() => { /* task gone — drop the filter */ })
|
||||||
|
: Promise.resolve(),
|
||||||
|
typeof q.user === 'string'
|
||||||
|
? userService.getAll({}, {s: q.user})
|
||||||
|
.then(users => {
|
||||||
|
selectedUser.value = (users as IUser[]).find(u => u.username === q.user) ?? null
|
||||||
|
})
|
||||||
|
.catch(() => { /* user not found — drop the filter */ })
|
||||||
|
: Promise.resolve(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Standalone page: drop any stale project so the app header shows this
|
||||||
|
// page's title instead of the last visited project.
|
||||||
|
baseStore.handleSetCurrentProject({project: null})
|
||||||
|
await restoreFromQuery()
|
||||||
|
ready.value = true
|
||||||
|
// One request with the fully-restored filter — no flicker through partial filters.
|
||||||
|
timeTrackingStore.browseEntries(filter.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// DatepickerWithRange only syncs its display from modelValue on change, and it
|
||||||
|
// remounts each time the modal opens — re-push the value so the range shows.
|
||||||
|
watch(filterModalOpen, open => {
|
||||||
|
if (open) {
|
||||||
|
nextTick(() => {
|
||||||
|
dateRange.value = {...dateRange.value}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(filterQuery, q => {
|
||||||
|
if (!ready.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.replace({query: q}).catch(() => { /* ignore redundant navigation */ })
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(filter, value => {
|
||||||
|
if (!ready.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timeTrackingStore.browseEntries(value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.time-tracking__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-block-end: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-tracking__range {
|
||||||
|
color: var(--grey-500);
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-tracking__buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-columns {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
> .field {
|
||||||
|
flex: 1;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The multiselect's per-row "click or press enter to select" hint is
|
||||||
|
// transparent but still reserves its (long) width, clipping the project/task
|
||||||
|
// title to a few characters in the narrow side-by-side columns. Drop it.
|
||||||
|
:deep(.hint-text) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filter-bubble-size: .75rem;
|
||||||
|
|
||||||
|
// Blue dot on the filter button when any filter is active (mirrors project views).
|
||||||
|
.has-filters {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: math.div($filter-bubble-size, -2);
|
||||||
|
inset-inline-end: math.div($filter-bubble-size, -2);
|
||||||
|
|
||||||
|
inline-size: $filter-bubble-size;
|
||||||
|
block-size: $filter-bubble-size;
|
||||||
|
border-radius: 100%;
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue