-
+
{{ t('projectPage.note') }}
+
-
-
-
- {{ t('projectPage.noteEmpty') }}
-
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('projectPage.noteEmpty') }}
+
+
@@ -367,11 +468,63 @@
{{ t('task.fields') }}
-
+
{{ row.term }}
- {{ row.value }}
- {{ row.value }}
+
+
+
+
+ {{ row.value }}
+
+
+
+
+
+
+
+
+
+
+ {{ row.value }}
+
+
+ {{ t('common.notSet') }}
+
+
+
+
+
+
@@ -498,6 +651,56 @@
.p-row.warn .p-value {
color: var(--danger, #f7768e);
}
+/* строки с инлайн-правкой: редактору нужен center, а не baseline */
+.p-row.editing {
+ align-items: center;
+}
+/* кликабельное значение параметра: курсор + подсветка по hover */
+.p-value.clickable {
+ cursor: pointer;
+}
+.p-value.clickable:hover {
+ color: var(--accent, #7aa2f7);
+}
+.p-value.empty {
+ color: var(--text-muted, #888);
+ font-weight: normal;
+}
+.p-value-badge {
+ cursor: pointer;
+}
+.inline-select {
+ min-width: 10rem;
+}
+/* название проекта как кнопка правки */
+.title-editable {
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ color: inherit;
+ text-align: inherit;
+ cursor: pointer;
+ overflow-wrap: anywhere;
+}
+.title-editable:hover {
+ color: var(--accent, #7aa2f7);
+}
+.title-input :deep(input) {
+ font: inherit;
+ font-size: inherit;
+ font-weight: inherit;
+}
+/* правка заметки: редактор + кнопки */
+.inline-note {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+.inline-note .inline-actions {
+ display: flex;
+ gap: 0.25rem;
+}
.muted {
color: var(--text-muted, #888);
font-size: 0.9em;
diff --git a/frontend/src/views/TaskView.vue b/frontend/src/views/TaskView.vue
index 6ddfaf0..5a7e2d6 100644
--- a/frontend/src/views/TaskView.vue
+++ b/frontend/src/views/TaskView.vue
@@ -2,7 +2,7 @@
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
-import { api, ApiError, type Attachment, type Tag, type Task } from '../api'
+import { api, ApiError, type Attachment, type Tag, type Task, type TaskUpdateInput } from '../api'
import {
deadlineLabel,
deadlineOverdue,
@@ -10,7 +10,11 @@
formatMinutes,
formatMoney,
priorityLabel,
+ priorityToGrade,
+ gradeToPriority,
priorityVariant,
+ priorityOptions,
+ deadlinePeriodOptions,
recurrenceLabel,
renderMarkdown,
setPageTitle,
@@ -20,6 +24,8 @@
} from '../taskui'
import { useToast } from 'gnexus-ui-kit/vue'
import { celebrateEarned } from '../gamification'
+import InlineField from '../components/InlineField.vue'
+import MdEditor from '../components/MdEditor.vue'
import TaskBadges from '../components/TaskBadges.vue'
import TaskForm from '../components/TaskForm.vue'
import TaskCard from '../components/TaskCard.vue'
@@ -85,11 +91,118 @@
void load()
})
+// ---------- Инлайн-редактирование полей ----------
+// Каждый InlineField правит своё поле: draft сеется на @open, ✓ шлёт PATCH
+// одного поля (или группы), ответ заменяет task.value — бейджи и шапка
+// обновляются сами.
+
+const draftTitle = ref('')
+const draftDescription = ref('')
+const descEditing = ref(false)
+const draftPriority = ref('')
+const draftEstimate = ref
(null)
+const draftActual = ref(null)
+const draftBudget = ref(null)
+const draftCost = ref(null)
+const draftDeadlineDate = ref('')
+const draftDeadlinePeriod = ref('')
+
+const priorityOptionsWithNone = computed(() => [
+ { value: '', label: t('common.none') },
+ ...priorityOptions(),
+])
+const deadlinePeriodsWithNone = computed(() => [
+ { value: '', label: t('common.none') },
+ ...deadlinePeriodOptions(),
+])
+
+// Инлайн-PATCH: ошибка → тост + throw (InlineField оставит редактор открытым)
+async function patchTask(patch: TaskUpdateInput) {
+ const task0 = task.value
+ if (!task0) return
+ try {
+ task.value = await api.updateTask(task0.id, patch)
+ tagNames.value = task.value.tags.map((t0) => t0.name)
+ } catch (e) {
+ toast.error({ title: t('common.error'), text: String(e) })
+ throw e
+ }
+}
+
+async function saveTitle() {
+ const title = draftTitle.value.trim()
+ if (!title) throw new Error('empty') // пустой заголовок не сохраняем
+ await patchTask({ title })
+}
+
+const descBusy = ref(false)
+
+async function saveDescription() {
+ descBusy.value = true
+ try {
+ await patchTask({ description: draftDescription.value })
+ descEditing.value = false
+ } catch {
+ // тост уже показан patchTask
+ } finally {
+ descBusy.value = false
+ }
+}
+
+async function savePriority() {
+ await patchTask({
+ priority: draftPriority.value === '' ? null : gradeToPriority(draftPriority.value),
+ })
+}
+
+// Пустая строка number-input — убираем значение (null), 0 — валидное число
+function numOrNull(v: number | null): number | null {
+ return v === null || Number.isNaN(v) ? null : v
+}
+
+async function saveEstimate() {
+ await patchTask({ estimated_minutes: numOrNull(draftEstimate.value) })
+}
+
+async function saveActual() {
+ await patchTask({ actual_minutes: numOrNull(draftActual.value) })
+}
+
+async function saveMoney() {
+ await patchTask({
+ budget_money: numOrNull(draftBudget.value),
+ cost_estimate_money: numOrNull(draftCost.value),
+ })
+}
+
+// Дедлайн: дата и период взаимоисключающие (как в ТЗ) — оба заполнены нельзя
+async function saveDeadline() {
+ if (draftDeadlineDate.value && draftDeadlinePeriod.value) {
+ toast.error({ title: t('task.deadlineConflict') })
+ throw new Error('conflict')
+ }
+ await patchTask({
+ deadline_date: draftDeadlineDate.value || null,
+ deadline_period: draftDeadlinePeriod.value || null,
+ })
+}
+
// Сайдбар «Параметры» — строка = иконка + термин + значение
+// Инлайн-правка параметров: строки показываются всегда (пустые — «не указано»
+// и кликабельны); taskType read-only (связан с recur_* — правится в полной форме)
const paramRows = computed(() => {
const task0 = task.value
if (!task0) return []
- const rows: { key: string; icon: string; term: string; value: string; warn?: boolean; badge?: string }[] = []
+ const rows: {
+ key: string
+ icon: string
+ term: string
+ value: string
+ warn?: boolean
+ badge?: string
+ empty?: boolean
+ inline?: boolean
+ }[] = []
rows.push({
key: 'taskType',
icon: task0.task_type === 'recurring' ? 'ph-repeat' : 'ph-arrow-bend-down-right',
@@ -99,53 +212,58 @@
? recurrenceLabel(task0) || t('stack.recur.recurring')
: t('stack.recur.oneTime'),
})
- if (task0.priority !== null)
- rows.push({
- key: 'priority',
- icon: 'ph-flag',
- term: t('task.field.priority'),
- value: priorityLabel(task0.priority),
- badge: priorityVariant(task0.priority),
- })
- if (task0.estimated_minutes)
- rows.push({
- key: 'estimate',
- icon: 'ph-clock',
- term: t('task.field.estimate'),
- value: `≈ ${formatMinutes(task0.estimated_minutes)}`,
- })
- if (task0.actual_minutes)
- rows.push({
- key: 'actual',
- icon: 'ph-timer',
- term: t('task.field.actual'),
- value: formatMinutes(task0.actual_minutes),
- })
- if (task0.budget_money !== null || task0.cost_estimate_money !== null)
- rows.push({
- key: 'money',
- icon: 'ph-coins',
- term: t('task.field.money'),
- value: [
- task0.budget_money !== null
- ? t('taskui.money.budget', { amount: formatMoney(task0.budget_money) })
- : '',
- task0.cost_estimate_money !== null
- ? t('taskui.money.estimate', { amount: formatMoney(task0.cost_estimate_money) })
- : '',
- ]
- .filter(Boolean)
- .join(' · '),
- })
+ rows.push({
+ key: 'priority',
+ icon: 'ph-flag',
+ term: t('task.field.priority'),
+ value: task0.priority === null ? '' : priorityLabel(task0.priority),
+ badge: task0.priority === null ? undefined : priorityVariant(task0.priority),
+ empty: task0.priority === null,
+ inline: true,
+ })
+ rows.push({
+ key: 'estimate',
+ icon: 'ph-clock',
+ term: t('task.field.estimate'),
+ value: task0.estimated_minutes ? `≈ ${formatMinutes(task0.estimated_minutes)}` : '',
+ empty: !task0.estimated_minutes,
+ inline: true,
+ })
+ rows.push({
+ key: 'actual',
+ icon: 'ph-timer',
+ term: t('task.field.actual'),
+ value: task0.actual_minutes ? formatMinutes(task0.actual_minutes) : '',
+ empty: !task0.actual_minutes,
+ inline: true,
+ })
+ rows.push({
+ key: 'money',
+ icon: 'ph-coins',
+ term: t('task.field.money'),
+ value: [
+ task0.budget_money !== null
+ ? t('taskui.money.budget', { amount: formatMoney(task0.budget_money) })
+ : '',
+ task0.cost_estimate_money !== null
+ ? t('taskui.money.estimate', { amount: formatMoney(task0.cost_estimate_money) })
+ : '',
+ ]
+ .filter(Boolean)
+ .join(' · '),
+ empty: task0.budget_money === null && task0.cost_estimate_money === null,
+ inline: true,
+ })
const deadline = deadlineLabel(task0)
- if (deadline)
- rows.push({
- key: 'deadline',
- icon: 'ph-alarm',
- term: t('task.field.deadline'),
- value: deadline,
- warn: deadlineOverdue(task0) && task0.status !== 'done' && task0.status !== 'cancelled',
- })
+ rows.push({
+ key: 'deadline',
+ icon: 'ph-alarm',
+ term: t('task.field.deadline'),
+ value: deadline,
+ warn: deadlineOverdue(task0) && task0.status !== 'done' && task0.status !== 'cancelled',
+ empty: !deadline,
+ inline: true,
+ })
return rows
})
@@ -356,6 +474,23 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -426,17 +561,45 @@
-
+
{{ t('task.description') }}
+
-
-
-
- {{ t('task.descriptionEmpty') }}
-
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('task.descriptionEmpty') }}
+
+
@@ -527,12 +690,167 @@
{{ t('task.fields') }}
-
-
+
+
{{ row.term }}
- {{ row.value }}
- {{ row.value }}
+
+
+
+
+
+
+
+ {{ row.value }}
+
+ {{ t('common.notSet') }}
+
+
+
+
+
+
+
+
+
+
+ {{ row.value || t('common.notSet') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.value || t('common.notSet') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.value || t('common.notSet') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.value || t('common.notSet') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.value }}
+ {{ row.value }}
+
@@ -674,6 +992,72 @@
.p-row.warn .p-value {
color: var(--danger, #f7768e);
}
+/* строки с инлайн-правкой: редактору нужен center, а не baseline */
+.p-row.editing {
+ align-items: center;
+}
+/* кликабельное значение параметра: курсор + подсветка по hover */
+.p-value.clickable {
+ cursor: pointer;
+}
+.p-value.clickable:hover {
+ color: var(--accent, #7aa2f7);
+}
+.p-value.empty {
+ color: var(--text-muted, #888);
+ font-weight: normal;
+}
+.p-value-badge {
+ cursor: pointer;
+}
+/* инлайн-редакторы в строках параметров */
+.inline-num {
+ width: 7rem;
+}
+.inline-num :deep(input) {
+ width: 7rem;
+}
+.pair-edit {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ align-items: center;
+}
+.pair-edit .inline-num {
+ flex: 1 1 6rem;
+}
+.inline-select {
+ min-width: 10rem;
+}
+/* заголовок страницы как кнопка правки */
+.title-editable {
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ color: inherit;
+ text-align: inherit;
+ cursor: pointer;
+ overflow-wrap: anywhere;
+}
+.title-editable:hover {
+ color: var(--accent, #7aa2f7);
+}
+.title-input :deep(input) {
+ font: inherit;
+ font-size: inherit;
+ font-weight: inherit;
+}
+/* правка описания: редактор + кнопки */
+.inline-desc {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+.inline-desc .inline-actions {
+ display: flex;
+ gap: 0.25rem;
+}
.proposal-actions {
margin-top: 1rem;
display: flex;