const Tasks = {
bundle: null,
employees: [],
orders: [],
chinaPurchases: [],
warehouseItems: [],
_loadSeq: 0,
currentTaskId: null,
createDraft: null,
templateDraft: null,
templateManagerOpen: false,
editorUi: {
visibleContexts: { order: false, project: false, china: false, warehouse: false },
restoredDraft: false,
},
view: 'list',
scope: 'my',
myMode: 'assigned',
isLoading: false,
calendarMonth: new Date().toISOString().slice(0, 7),
filters: {
search: '',
status: '',
priority: '',
assignee_id: '',
reporter_id: '',
project_id: '',
order_id: '',
area_id: '',
due: '',
mine: false,
awaiting_review: false,
waiting_only: false,
},
sort: 'manual',
_completedOpen: Object.create(null),
_draftSaveTimer: null,
_deletingTaskIds: Object.create(null),
_saveState: null,
emptyBundle() {
return {
areas: [],
projects: [],
tasks: [],
bugReports: [],
comments: [],
assets: [],
checklistItems: [],
watchers: [],
activity: [],
templates: [],
};
},
normalizeBundle(bundle = {}) {
return {
...this.emptyBundle(),
...(bundle || {}),
};
},
hydrateFromCache() {
if (typeof getLocal !== 'function' || typeof LOCAL_KEYS === 'undefined') return false;
const cachedBundle = this.normalizeBundle({
areas: getLocal(LOCAL_KEYS.workAreas) || [],
projects: getLocal(LOCAL_KEYS.workProjects) || [],
tasks: getLocal(LOCAL_KEYS.workTasks) || [],
bugReports: getLocal(LOCAL_KEYS.bugReports) || [],
comments: getLocal(LOCAL_KEYS.taskComments) || [],
assets: getLocal(LOCAL_KEYS.workAssets) || [],
checklistItems: getLocal(LOCAL_KEYS.taskChecklistItems) || [],
watchers: getLocal(LOCAL_KEYS.taskWatchers) || [],
activity: getLocal(LOCAL_KEYS.workActivity) || [],
templates: getLocal(LOCAL_KEYS.workTemplatesV2) || [],
});
const hasBundleData = Object.values(cachedBundle).some(value => Array.isArray(value) && value.length > 0);
if (hasBundleData) {
this.bundle = cachedBundle;
}
const cachedEmployees = getLocal(LOCAL_KEYS.employees) || [];
if (cachedEmployees.length > 0) this.employees = cachedEmployees;
const cachedOrders = (getLocal(LOCAL_KEYS.orders) || []).filter(item => item.status !== 'deleted');
if (cachedOrders.length > 0) this.orders = cachedOrders;
const cachedChina = getLocal(LOCAL_KEYS.chinaPurchases) || [];
if (cachedChina.length > 0) this.chinaPurchases = cachedChina;
const cachedWarehouse = getLocal(LOCAL_KEYS.warehouseItems) || [];
if (cachedWarehouse.length > 0) this.warehouseItems = cachedWarehouse;
return hasBundleData;
},
async load(taskId) {
if (!this.scope) this.scope = App.currentEmployeeId ? 'my' : 'all';
if (this.scope === 'my' && !App.currentEmployeeId) {
this.scope = 'all';
}
if (taskId) {
this.currentTaskId = Number(taskId);
this.createDraft = null;
}
const loadSeq = ++this._loadSeq;
const hasCachedBundle = !!this.bundle || this.hydrateFromCache();
this.isLoading = !hasCachedBundle;
this.render();
const secondaryPromise = this.refreshSecondaryData()
.then(() => {
if (this._loadSeq !== loadSeq) return;
this.render();
})
.catch(error => {
console.warn('Tasks secondary load error:', error);
});
try {
await this.refreshPrimaryData();
if (this._loadSeq !== loadSeq) return;
this.isLoading = false;
this.render();
} catch (error) {
console.error('Tasks load error:', error);
if (this._loadSeq === loadSeq) {
this.isLoading = false;
this.render();
}
}
secondaryPromise.catch(() => {});
},
async refreshPrimaryData() {
const [
areas,
projects,
tasks,
templates,
employees,
] = await Promise.all([
loadWorkAreas(),
loadWorkProjects(),
loadWorkTasks(),
loadWorkTemplatesV2(),
loadEmployees(),
]);
this.bundle = this.normalizeBundle({
...(this.bundle || {}),
areas,
projects,
tasks,
templates,
});
this.employees = employees;
},
async refreshSecondaryData() {
const [
bugReports,
comments,
assets,
checklistItems,
watchers,
activity,
orders,
chinaPurchases,
warehouseItems,
] = await Promise.all([
loadBugReports(),
loadTaskComments(),
loadWorkAssets(),
loadTaskChecklistItems(),
loadTaskWatchers(),
loadWorkActivity(),
loadOrders({}),
loadChinaPurchases({}),
loadWarehouseItems(),
]);
this.bundle = this.normalizeBundle({
...(this.bundle || {}),
bugReports,
comments,
assets,
checklistItems,
watchers,
activity,
});
this.orders = orders;
this.chinaPurchases = chinaPurchases;
this.warehouseItems = warehouseItems;
},
async refreshData() {
await Promise.all([
this.refreshPrimaryData(),
this.refreshSecondaryData(),
]);
},
esc(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
},
todayYmd() {
return new Date().toISOString().slice(0, 10);
},
currentEmployee() {
return (this.employees || []).find(item => String(item.id) === String(App.currentEmployeeId)) || null;
},
taskById(taskId) {
return (this.bundle?.tasks || []).find(item => String(item.id) === String(taskId)) || null;
},
projectById(projectId) {
return (this.bundle?.projects || []).find(item => String(item.id) === String(projectId)) || null;
},
areaById(areaId) {
return (this.bundle?.areas || []).find(item => String(item.id) === String(areaId)) || null;
},
orderById(orderId) {
return (this.orders || []).find(item => String(item.id) === String(orderId)) || null;
},
employeeNameById(id, fallback) {
return (this.employees || []).find(item => String(item.id) === String(id))?.name || fallback || '—';
},
chinaPurchaseById(id) {
return (this.chinaPurchases || []).find(item => String(item.id) === String(id)) || null;
},
warehouseItemById(id) {
return (this.warehouseItems || []).find(item => String(item.id) === String(id)) || null;
},
areaBySlug(slug) {
const normalized = WorkManagementCore.normalizeText(slug);
return (this.bundle?.areas || []).find(item => WorkManagementCore.normalizeText(item.slug || item.name || '') === normalized) || null;
},
visibleContextsForTask(task) {
const fallback = {
order: !!task?.order_id,
project: !!task?.project_id,
china: !!task?.china_purchase_id,
warehouse: !!task?.warehouse_item_id,
};
return {
order: !!(this.editorUi?.visibleContexts?.order || fallback.order),
project: !!(this.editorUi?.visibleContexts?.project || fallback.project),
china: !!(this.editorUi?.visibleContexts?.china || fallback.china),
warehouse: !!(this.editorUi?.visibleContexts?.warehouse || fallback.warehouse),
};
},
syncEditorUiForTask(task) {
this.editorUi = {
...(this.editorUi || {}),
visibleContexts: {
order: !!task?.order_id,
project: !!task?.project_id,
china: !!task?.china_purchase_id,
warehouse: !!task?.warehouse_item_id,
},
restoredDraft: false,
};
},
inferHiddenAreaId(taskLike = {}) {
if (taskLike.area_id) return Number(taskLike.area_id) || taskLike.area_id;
if (taskLike.project_id) {
const project = this.projectById(taskLike.project_id);
if (project?.area_id) return project.area_id;
}
if (taskLike.china_purchase_id) return this.areaBySlug('china')?.id || '';
if (taskLike.warehouse_item_id) return this.areaBySlug('warehouse')?.id || '';
return this.areaBySlug('general')?.id || '';
},
inferPrimaryContextKind(taskLike = {}) {
if (taskLike.project_id) return 'project';
if (taskLike.order_id) return 'order';
return 'area';
},
meaningfulDraft(taskLike = {}) {
return !!(
String(taskLike.title || '').trim()
|| String(taskLike.description || '').trim()
|| String(taskLike.waiting_for_text || '').trim()
|| String(taskLike.template_id || '').trim()
|| String(taskLike.assignee_id || '').trim()
|| String(taskLike.due_date || '').trim()
|| String(taskLike.order_id || '').trim()
|| String(taskLike.project_id || '').trim()
|| String(taskLike.china_purchase_id || '').trim()
|| String(taskLike.warehouse_item_id || '').trim()
|| (Array.isArray(taskLike.watcher_ids) && taskLike.watcher_ids.length > 0)
);
},
draftStorageKey() {
return `ro_task_editor_draft_v3:${App.currentEmployeeId || 'guest'}`;
},
draftTtlMs() {
return 30 * 60 * 1000;
},
loadStoredDraft() {
try {
const raw = localStorage.getItem(this.draftStorageKey());
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return null;
if (!parsed.saved_at || (Date.now() - Number(parsed.saved_at)) > this.draftTtlMs()) {
localStorage.removeItem(this.draftStorageKey());
return null;
}
return parsed.draft || null;
} catch (error) {
return null;
}
},
persistStoredDraft(draft) {
try {
if (!draft || !this.meaningfulDraft(draft)) {
localStorage.removeItem(this.draftStorageKey());
return;
}
localStorage.setItem(this.draftStorageKey(), JSON.stringify({
saved_at: Date.now(),
draft,
}));
} catch (error) {
// Ignore storage issues in the browser.
}
},
clearStoredDraft() {
try {
localStorage.removeItem(this.draftStorageKey());
} catch (error) {
// Ignore storage issues in the browser.
}
},
maybeRestoreCreateDraft(preset = {}, templateId = null) {
const hasPreset = Object.keys(preset || {}).some(key => {
const value = preset[key];
return value !== null && value !== undefined && String(value) !== '';
});
if (templateId || hasPreset) return null;
return this.loadStoredDraft();
},
formatDate(value) {
if (!value) return '—';
try {
return new Date(value).toLocaleDateString('ru-RU');
} catch (error) {
return value;
}
},
formatTaskDue(task) {
if (!task?.due_date) return '—';
return `${this.formatDate(task.due_date)}${task.due_time ? ` ${task.due_time}` : ''}`;
},
isOverdue(task) {
return WorkManagementCore.isTaskOverdue(task);
},
statusLabel(status) {
return WorkManagementCore.getTaskStatusLabel(status);
},
priorityLabel(priority) {
return (WorkManagementCore.TASK_PRIORITY_OPTIONS.find(item => item.value === priority) || WorkManagementCore.TASK_PRIORITY_OPTIONS[1]).label;
},
priorityBadgeClass(priority) {
if (priority === 'urgent') return 'badge-red';
if (priority === 'high') return 'badge-yellow';
if (priority === 'low') return 'badge-gray';
return 'badge-blue';
},
bugSeverityLabel(severity) {
if (severity === 'critical') return 'Критичный';
if (severity === 'high') return 'Высокий';
if (severity === 'low') return 'Низкий';
return 'Средний';
},
bugSeverityBadgeClass(severity) {
if (severity === 'critical') return 'badge-red';
if (severity === 'high') return 'badge-yellow';
if (severity === 'low') return 'badge-gray';
return 'badge-blue';
},
bugPromptStatusLabel(status, hasPrompt) {
if (status === 'failed') return 'Ошибка prompt';
if (status === 'prompt_ready' || hasPrompt) return 'Prompt готов';
if (status === 'pending') return 'Prompt в очереди';
return status || 'Без prompt';
},
bugPromptBadgeClass(status, hasPrompt) {
if (status === 'failed') return 'badge-red';
if (status === 'prompt_ready' || hasPrompt) return 'badge-green';
if (status === 'pending') return 'badge-yellow';
return 'badge-gray';
},
bugPromptText(report) {
if (!report) return '';
return String(
report.codex_prompt
|| report.prompt
|| report.codex_result
|| ''
).trim();
},
contextLabel(task) {
const project = task.project_id ? this.projectById(task.project_id) : null;
const order = task.order_id ? this.orderById(task.order_id) : (project?.linked_order_id ? this.orderById(project.linked_order_id) : null);
const chunks = [];
if (order) chunks.push(`Заказ: ${order.order_name}`);
if (project) chunks.push(`Проект: ${project.title}`);
if (task.china_purchase_id) {
const china = this.chinaPurchaseById(task.china_purchase_id);
if (china) chunks.push(`China: ${china.purchase_name || `Закупка #${china.id}`}`);
}
if (task.warehouse_item_id) {
const warehouse = this.warehouseItemById(task.warehouse_item_id);
if (warehouse) chunks.push(`Склад: ${warehouse.name || warehouse.sku || `Позиция #${warehouse.id}`}`);
}
const area = task.area_id ? this.areaById(task.area_id) : (project?.area_id ? this.areaById(project.area_id) : null);
if (!chunks.length && area && WorkManagementCore.normalizeText(area.slug || area.name || '') !== 'general') {
chunks.push(`Направление: ${area.name}`);
}
return chunks.join(' · ') || 'Без привязки';
},
commentsForTask(taskId) {
return (this.bundle?.comments || [])
.filter(item => String(item.task_id) === String(taskId))
.sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')));
},
assetsForTask(taskId) {
return (this.bundle?.assets || [])
.filter(item => String(item.task_id) === String(taskId))
.sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')));
},
checklistForTask(taskId) {
return (this.bundle?.checklistItems || [])
.filter(item => String(item.task_id) === String(taskId))
.sort((a, b) => (Number(a.sort_index) || 0) - (Number(b.sort_index) || 0));
},
watcherIdsForTask(taskId) {
return (this.bundle?.watchers || [])
.filter(item => String(item.task_id) === String(taskId))
.map(item => Number(item.user_id));
},
bugReportForTask(taskId) {
return (this.bundle?.bugReports || [])
.filter(item => String(item.task_id || '') === String(taskId))
.sort((a, b) => String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || '')))[0] || null;
},
activityForTask(taskId) {
return (this.bundle?.activity || [])
.filter(item => String(item.task_id) === String(taskId))
.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
},
subtasksForTask(taskId) {
return (this.bundle?.tasks || [])
.filter(item => String(item.parent_task_id || '') === String(taskId))
.sort((a, b) => (Number(a.sort_index) || 0) - (Number(b.sort_index) || 0));
},
getTasksForOrder(orderId) {
const projectsForOrder = (this.bundle?.projects || []).filter(item => String(item.linked_order_id || '') === String(orderId));
const projectIds = new Set(projectsForOrder.map(item => String(item.id)));
return (this.bundle?.tasks || []).filter(task =>
String(task.order_id || '') === String(orderId)
|| projectIds.has(String(task.project_id || ''))
);
},
populateFilters() {
// Backward compatibility no-op.
},
openTask(taskId) {
this.templateManagerOpen = false;
this.templateDraft = null;
this.createDraft = null;
this._saveState = null;
this.currentTaskId = Number(taskId);
this.syncEditorUiForTask(this.taskById(taskId));
if (App.currentPage !== 'tasks') {
App.navigate('tasks', true, taskId);
return;
}
this.render();
},
showAddForm(orderId, _orderName) {
this.openCreate(orderId ? { order_id: orderId } : {});
},
openCreate(preset = {}, templateId = null) {
this.templateManagerOpen = false;
this.templateDraft = null;
this._saveState = null;
const template = templateId
? (this.bundle?.templates || []).find(item => String(item.id) === String(templateId))
: null;
const project = preset.project_id ? this.projectById(preset.project_id) : null;
const restored = this.maybeRestoreCreateDraft(preset, templateId);
const baseDraft = restored || {};
this.createDraft = {
id: '',
template_id: preset.template_id || template?.id || baseDraft.template_id || '',
title: preset.title || template?.title || baseDraft.title || '',
description: preset.description || template?.description || baseDraft.description || '',
status: preset.status || baseDraft.status || 'incoming',
priority: preset.priority || template?.default_priority || baseDraft.priority || 'normal',
reporter_id: preset.reporter_id || baseDraft.reporter_id || App.currentEmployeeId || '',
assignee_id: preset.assignee_id || baseDraft.assignee_id || '',
reviewer_id: '',
area_id: preset.area_id || baseDraft.area_id || project?.area_id || template?.suggested_area_id || '',
order_id: preset.order_id || baseDraft.order_id || project?.linked_order_id || '',
project_id: preset.project_id || baseDraft.project_id || '',
china_purchase_id: preset.china_purchase_id || baseDraft.china_purchase_id || '',
warehouse_item_id: preset.warehouse_item_id || baseDraft.warehouse_item_id || '',
primary_context_kind: this.inferPrimaryContextKind({
...baseDraft,
...preset,
project_id: preset.project_id || baseDraft.project_id || '',
order_id: preset.order_id || baseDraft.order_id || project?.linked_order_id || '',
}),
due_date: preset.due_date || baseDraft.due_date || '',
due_time: preset.due_time || baseDraft.due_time || '',
waiting_for_text: preset.waiting_for_text || baseDraft.waiting_for_text || '',
parent_task_id: preset.parent_task_id || baseDraft.parent_task_id || '',
watcher_ids: preset.watcher_ids || baseDraft.watcher_ids || [],
};
this.editorUi = {
...this.editorUi,
visibleContexts: {
order: !!(preset.order_id || baseDraft.order_id || project?.linked_order_id),
project: !!(preset.project_id || baseDraft.project_id),
china: !!(preset.china_purchase_id || baseDraft.china_purchase_id),
warehouse: !!(preset.warehouse_item_id || baseDraft.warehouse_item_id),
},
restoredDraft: !!restored,
};
this.currentTaskId = null;
if (App.currentPage !== 'tasks') {
App.navigate('tasks');
return;
}
this.render();
},
cancelEditor() {
if (this._saveState?.active) {
App.toast('Подожди, задача ещё сохраняется');
return;
}
if (this.createDraft) {
const draft = this.readEditorForm();
if (this.meaningfulDraft(draft)) {
this.persistStoredDraft({
...this.createDraft,
...draft,
});
App.toast('Черновик задачи сохранен локально');
} else {
this.clearStoredDraft();
}
}
this.createDraft = null;
this.currentTaskId = null;
this._saveState = null;
this.editorUi = {
...this.editorUi,
visibleContexts: { order: false, project: false, china: false, warehouse: false },
restoredDraft: false,
};
const drawer = document.getElementById('task-drawer-overlay');
if (drawer) {
drawer.classList.remove('is-open');
document.body.style.overflow = '';
setTimeout(() => { drawer.remove(); }, 250);
}
},
setView(view) {
this.view = view;
this.render();
},
setScope(scope) {
this.scope = scope;
this.render();
},
setMyMode(mode) {
this.myMode = mode || 'assigned';
this.render();
},
setSort(value) {
this.sort = value || 'manual';
this.render();
},
_filtersOpen: false,
toggleFilters() {
this._filtersOpen = !this._filtersOpen;
const panel = document.getElementById('tasks-filters-panel');
if (panel) panel.style.display = this._filtersOpen ? '' : 'none';
document.querySelector('.tasks-filter-toggle')?.classList.toggle('is-open', this._filtersOpen);
},
resetFilters() {
this.filters = { search: '', status: '', priority: '', assignee_id: '', reporter_id: '', project_id: '', order_id: '', area_id: '', due: '', mine: false, awaiting_review: false, waiting_only: false };
this.sort = 'manual';
this.render();
},
updateFilter(field, value) {
this.filters[field] = value;
this.render();
},
viewTabsHtml() {
return `
`;
},
scopeTabsHtml() {
const currentEmployee = this.currentEmployee();
const myCount = this.activeTasksCount(this.myTasks('assigned'));
const allCount = this.activeTasksCount(this.bundle?.tasks || []);
const overdueCount = this.activeTasksCount((this.bundle?.tasks || []).filter(task => this.isOverdue(task)));
return `
`;
},
myModeTabsHtml() {
if (this.scope !== 'my' || !App.currentEmployeeId) return '';
const assignedCount = this.activeTasksCount(this.myTasks('assigned'));
const outgoingCount = this.activeTasksCount(this.myTasks('outgoing'));
const allMyCount = this.activeTasksCount(this.myTasks('all'));
return `
`;
},
myTasks(mode = 'assigned') {
const currentEmployeeId = String(App.currentEmployeeId || '');
if (!currentEmployeeId) return [];
return (this.bundle?.tasks || []).filter(task => {
const assigneeId = String(task.assignee_id || '');
const reporterId = String(task.reporter_id || '');
const watcherMatch = this.watcherIdsForTask(task.id).some(id => String(id) === currentEmployeeId);
if (mode === 'outgoing') return reporterId === currentEmployeeId;
if (mode === 'all') return assigneeId === currentEmployeeId || reporterId === currentEmployeeId || watcherMatch;
return assigneeId === currentEmployeeId;
});
},
isTaskFinished(task) {
return WorkManagementCore.isTaskFinished(task);
},
activeTasksCount(list) {
return (list || []).filter(task => !this.isTaskFinished(task)).length;
},
completedTasksCount(list) {
return (list || []).filter(task => this.isTaskFinished(task)).length;
},
splitTasksByCompletion(list) {
return (list || []).reduce((acc, task) => {
if (this.isTaskFinished(task)) acc.completed.push(task);
else acc.active.push(task);
return acc;
}, { active: [], completed: [] });
},
activeFeedTitle() {
if (this.scope === 'overdue') return 'Просроченные задачи';
if (this.scope === 'all') return 'Актуальные задачи';
if (this.myMode === 'outgoing') return 'Исходящие задачи';
if (this.myMode === 'all') return 'Мои активные задачи';
return 'Задачи, поставленные мне';
},
activeFeedSubtitle() {
if (this.scope === 'overdue') return 'То, что уже требует внимания прямо сейчас.';
if (this.scope === 'all') return 'Здесь только то, что еще в работе. Готовые задачи убраны ниже.';
if (this.myMode === 'outgoing') return 'Задачи, которые вы поставили другим сотрудникам, чтобы быстро проверять статус.';
if (this.myMode === 'all') return 'Все мои входящие и исходящие задачи без завершенных.';
return 'То, что сейчас нужно делать вам. Готовые задачи убраны в скрытый блок ниже.';
},
completedPanelKey() {
return `${this.scope}:${this.myMode}:${this.filters.search}:${this.view}`;
},
isCompletedPanelOpen() {
return !!this._completedOpen[this.completedPanelKey()];
},
toggleCompletedPanel() {
const key = this.completedPanelKey();
this._completedOpen[key] = !this._completedOpen[key];
this.render();
},
collectTaskTreeIds(rootTaskId) {
const rootId = Number(rootTaskId);
if (!rootId) return [];
const byParent = new Map();
(this.bundle?.tasks || []).forEach(task => {
const parentKey = String(task.parent_task_id || '');
const bucket = byParent.get(parentKey) || [];
bucket.push(Number(task.id));
byParent.set(parentKey, bucket);
});
const result = [];
const queue = [rootId];
const seen = new Set();
while (queue.length > 0) {
const current = Number(queue.shift());
if (!current || seen.has(current)) continue;
seen.add(current);
result.push(current);
const children = byParent.get(String(current)) || [];
children.forEach(childId => {
if (!seen.has(childId)) queue.push(childId);
});
}
return result;
},
isTaskDeleting(taskId) {
return !!this._deletingTaskIds[String(taskId)];
},
filteredTasks() {
const search = WorkManagementCore.normalizeText(this.filters.search);
const commentsByTask = new Map();
this.bundle?.comments?.forEach(comment => {
const bucket = commentsByTask.get(String(comment.task_id)) || [];
bucket.push(comment.body || '');
commentsByTask.set(String(comment.task_id), bucket);
});
let list = (this.bundle?.tasks || []).slice()
.filter(task => !this.isTaskDeleting(task.id));
if (this.scope === 'my') {
if (!App.currentEmployeeId) return list;
list = this.myTasks(this.myMode);
}
if (this.scope === 'overdue') {
list = list.filter(task => this.isOverdue(task));
}
if (this.filters.status) list = list.filter(task => task.status === this.filters.status);
if (this.filters.priority) list = list.filter(task => task.priority === this.filters.priority);
if (this.filters.assignee_id) list = list.filter(task => String(task.assignee_id || '') === String(this.filters.assignee_id));
if (this.filters.reporter_id) list = list.filter(task => String(task.reporter_id || '') === String(this.filters.reporter_id));
if (this.filters.project_id) list = list.filter(task => String(task.project_id || '') === String(this.filters.project_id));
if (this.filters.order_id) {
list = list.filter(task => {
if (String(task.order_id || '') === String(this.filters.order_id)) return true;
const project = task.project_id ? this.projectById(task.project_id) : null;
return String(project?.linked_order_id || '') === String(this.filters.order_id);
});
}
if (this.filters.area_id) {
list = list.filter(task => {
if (String(task.area_id || '') === String(this.filters.area_id)) return true;
const project = task.project_id ? this.projectById(task.project_id) : null;
return String(project?.area_id || '') === String(this.filters.area_id);
});
}
if (this.filters.mine && App.currentEmployeeId) {
list = list.filter(task => String(task.assignee_id || '') === String(App.currentEmployeeId));
}
if (this.filters.awaiting_review) list = list.filter(task => task.status === 'review');
if (this.filters.waiting_only) list = list.filter(task => task.status === 'waiting');
if (this.filters.due === 'overdue') list = list.filter(task => this.isOverdue(task));
if (this.filters.due === 'today') list = list.filter(task => task.due_date === this.todayYmd());
if (this.filters.due === 'week') {
const now = new Date();
const weekLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
list = list.filter(task => task.due_date && task.due_date >= this.todayYmd() && task.due_date <= weekLater.toISOString().slice(0, 10));
}
if (this.filters.due === 'no_deadline') list = list.filter(task => !task.due_date);
if (search) {
list = list.filter(task => {
const project = task.project_id ? this.projectById(task.project_id) : null;
const order = task.order_id ? this.orderById(task.order_id) : (project?.linked_order_id ? this.orderById(project.linked_order_id) : null);
const china = task.china_purchase_id ? this.chinaPurchaseById(task.china_purchase_id) : null;
const warehouse = task.warehouse_item_id ? this.warehouseItemById(task.warehouse_item_id) : null;
const haystack = WorkManagementCore.normalizeText([
task.title,
task.description,
task.assignee_name,
task.reporter_name,
task.waiting_for_text,
project?.title,
order?.order_name,
china?.purchase_name,
warehouse?.name,
warehouse?.sku,
...(commentsByTask.get(String(task.id)) || []),
].join(' '));
return haystack.includes(search);
});
}
list.sort((a, b) => {
if (this.sort === 'priority') {
return WorkManagementCore.priorityWeight(b.priority) - WorkManagementCore.priorityWeight(a.priority)
|| String(WorkManagementCore.buildTaskDueIso(a)).localeCompare(String(WorkManagementCore.buildTaskDueIso(b)), 'ru');
}
if (this.sort === 'due') {
return String(WorkManagementCore.buildTaskDueIso(a)).localeCompare(String(WorkManagementCore.buildTaskDueIso(b)), 'ru')
|| WorkManagementCore.priorityWeight(b.priority) - WorkManagementCore.priorityWeight(a.priority);
}
if (this.sort === 'created') {
return String(b.created_at || '').localeCompare(String(a.created_at || ''));
}
return (Number(a.sort_index) || 0) - (Number(b.sort_index) || 0)
|| WorkManagementCore.priorityWeight(b.priority) - WorkManagementCore.priorityWeight(a.priority)
|| String(a.title || '').localeCompare(String(b.title || ''), 'ru');
});
return list;
},
statsCardsHtml() {
const all = this.bundle?.tasks || [];
const overdue = all.filter(task => this.isOverdue(task)).length;
const review = all.filter(task => task.status === 'review').length;
const waiting = all.filter(task => task.status === 'waiting').length;
return `
📋
${this.activeTasksCount(all)}
Активные задачи
👤
${this.activeTasksCount(this.myTasks('assigned'))}
Поставили мне
💬
${this.activeTasksCount(this.myTasks('outgoing'))}
Поставил я
${overdue > 0 ? '⚠' : '✅'}
`;
},
filtersHtml() {
const assigneeOptions = (this.employees || [])
.filter(item => item.is_active !== false)
.map(item => ``)
.join('');
const reporterOptions = assigneeOptions;
const projectOptions = (this.bundle?.projects || [])
.map(item => ``)
.join('');
const orderOptions = (this.orders || [])
.filter(item => item.status !== 'deleted')
.map(item => ``)
.join('');
const areaOptions = (this.bundle?.areas || [])
.map(item => ``)
.join('');
const statusOptions = WorkManagementCore.TASK_STATUS_OPTIONS
.map(item => ``)
.join('');
const priorityOptions = WorkManagementCore.TASK_PRIORITY_OPTIONS
.map(item => ``)
.join('');
return `
`;
},
inlineStatusSelectHtml(task) {
return `
`;
},
renderRowActions(task, { showManualMoves = false } = {}) {
const isDeleting = this.isTaskDeleting(task.id);
return `
${showManualMoves ? `
` : ''}
`;
},
renderListView(tasks, options = {}) {
const showManualMoves = this.sort === 'manual' && !options.disableManualMoves;
const rows = tasks.map(task => `
|
${this.esc(task.title)}
${this.esc(this.contextLabel(task))}
|
${this.inlineStatusSelectHtml(task)} |
${this.esc(this.priorityLabel(task.priority))} |
${this.esc(task.assignee_name || this.employeeNameById(task.assignee_id, '—'))} |
${this.esc(task.reporter_name || this.employeeNameById(task.reporter_id, '—'))} |
${this.esc(this.formatTaskDue(task))} |
${this.renderRowActions(task, { showManualMoves })} |
`).join('');
return `
| Задача |
Статус |
Приоритет |
Исполнитель |
Постановщик |
Дедлайн |
Действия |
${rows || '| Нет задач |
'}
`;
},
renderCompletedSection(tasks) {
if (!tasks.length) return '';
const isOpen = this.isCompletedPanelOpen();
return `
${this.renderListView(tasks, { disableManualMoves: true })}
`;
},
renderKanbanView(tasks) {
const columns = WorkManagementCore.TASK_STATUS_OPTIONS.map(status => {
const columnTasks = tasks.filter(task => task.status === status.value);
return `
${columnTasks.map(task => `
${this.esc(task.title)}
${this.esc(this.priorityLabel(task.priority))}
${this.esc(this.formatTaskDue(task))}
${this.esc(task.assignee_name || this.employeeNameById(task.assignee_id, '—'))}
`).join('')}
`;
}).join('');
return `${columns}
`;
},
renderCalendarView(tasks) {
const [year, month] = this.calendarMonth.split('-').map(value => Number(value));
const firstDay = new Date(year, month - 1, 1);
const daysInMonth = new Date(year, month, 0).getDate();
const startWeekday = (firstDay.getDay() + 6) % 7;
const cells = [];
for (let i = 0; i < startWeekday; i += 1) {
cells.push('');
}
for (let day = 1; day <= daysInMonth; day += 1) {
const dateKey = `${this.calendarMonth}-${String(day).padStart(2, '0')}`;
const dayTasks = tasks.filter(task => task.due_date === dateKey);
cells.push(`
${day}
${dayTasks.map(task => `
`).join('')}
`);
}
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' });
return `
`;
},
renderMainContent(tasks) {
const groups = this.splitTasksByCompletion(tasks);
const activeMarkup = this.view === 'kanban'
? this.renderKanbanView(groups.active)
: this.view === 'calendar'
? this.renderCalendarView(groups.active)
: this.renderListView(groups.active);
const emptyMarkup = `
✅
Здесь нет актуальных задач.
`;
return `
${groups.active.length ? activeMarkup : emptyMarkup}
${this.renderCompletedSection(groups.completed)}
`;
},
editorTask() {
return this.createDraft || (this.currentTaskId ? this.taskById(this.currentTaskId) : null);
},
editorWatcherIds(task) {
if (!task) return [];
const ids = new Set(this.createDraft ? (this.createDraft.watcher_ids || []) : this.watcherIdsForTask(task.id));
if (task.reviewer_id) ids.add(Number(task.reviewer_id));
return Array.from(ids).filter(Boolean);
},
editorTitle(task) {
return this.createDraft ? 'Новая задача' : 'Карточка задачи';
},
resetTemplateDraft(template = null) {
this.templateDraft = template
? {
id: template.id,
kind: 'task',
name: template.name || '',
title: template.title || '',
description: template.description || '',
default_priority: template.default_priority || 'normal',
checklist_items: Array.isArray(template.checklist_items) ? template.checklist_items.join('\n') : '',
suggested_subtasks: Array.isArray(template.suggested_subtasks) ? template.suggested_subtasks.join('\n') : '',
}
: {
id: '',
kind: 'task',
name: '',
title: '',
description: '',
default_priority: 'normal',
checklist_items: '',
suggested_subtasks: '',
};
if (this.templateManagerOpen) this.renderTemplateManager();
},
openTemplateManager(templateId = null) {
const selectedId = templateId
|| document.getElementById('task-editor-template')?.value
|| this.createDraft?.template_id
|| this.defaultTaskTemplateId();
const template = (this.bundle?.templates || []).find(item => item.kind === 'task' && String(item.id) === String(selectedId));
this.templateManagerOpen = true;
this.resetTemplateDraft(template || null);
this.renderTemplateManager();
},
closeTemplateManager() {
this.templateManagerOpen = false;
this.templateDraft = null;
const drawer = document.getElementById('task-template-overlay');
if (drawer) {
drawer.classList.remove('is-open');
setTimeout(() => { drawer.remove(); }, 250);
}
},
taskTemplateOptionsHtml(selectedTemplateId) {
return (this.bundle?.templates || [])
.filter(item => item.kind === 'task')
.map(item => ``)
.join('');
},
statusOptionsHtml(selected) {
return WorkManagementCore.TASK_STATUS_OPTIONS
.map(item => ``)
.join('');
},
priorityOptionsHtml(selected) {
return WorkManagementCore.TASK_PRIORITY_OPTIONS
.map(item => ``)
.join('');
},
employeeOptionsHtml(selected) {
return (this.employees || [])
.filter(item => item.is_active !== false)
.map(item => ``)
.join('');
},
orderOptionsHtml(selected) {
return (this.orders || [])
.filter(item => item.status !== 'deleted')
.map(item => ``)
.join('');
},
projectOptionsHtml(selected) {
return (this.bundle?.projects || [])
.map(item => ``)
.join('');
},
areaOptionsHtml(selected) {
return (this.bundle?.areas || [])
.map(item => ``)
.join('');
},
chinaOptionsHtml(selected) {
return (this.chinaPurchases || [])
.map(item => ``)
.join('');
},
warehouseOptionsHtml(selected) {
return (this.warehouseItems || [])
.map(item => ``)
.join('');
},
contextToggleButtonsHtml(task) {
const visible = this.visibleContextsForTask(task);
const items = [
{ key: 'order', label: 'Заказ', active: visible.order },
{ key: 'project', label: 'Проект', active: visible.project },
{ key: 'china', label: 'China', active: visible.china },
{ key: 'warehouse', label: 'Склад', active: visible.warehouse },
];
return `
${items.map(item => `
`).join('')}
`;
},
contextSelectHtml(config) {
return `
`;
},
renderChecklistSection(task) {
if (!task?.id) {
return 'Сначала сохраните задачу, затем добавьте чек-лист, комментарии и файлы.
';
}
const items = this.checklistForTask(task.id);
return `
${items.length === 0 ? '
Пока нет пунктов
' : items.map(item => `
`).join('')}
`;
},
renderSubtasksSection(task) {
if (!task?.id) return '';
const subtasks = this.subtasksForTask(task.id);
return `
${subtasks.length === 0
? '
Подзадач пока нет
'
: subtasks.map(item => `
`).join('')}
`;
},
renderCommentsSection(task) {
if (!task?.id) return '';
const comments = this.commentsForTask(task.id);
return `
`;
},
renderAssetsSection(task) {
if (!task?.id) return '';
const assets = this.assetsForTask(task.id);
return `
${assets.length === 0
? '
Пока ничего не добавлено
'
: assets.map(asset => asset.kind === 'file'
? `
${this.esc(asset.title || asset.file_name || 'Файл')}
${this.esc(asset.file_name || '')}
`
: `
${this.esc(asset.title || 'Ссылка')}
${this.esc(asset.url || '')}
`
).join('')}
`;
},
renderActivitySection(task) {
if (!task?.id) return '';
const items = this.activityForTask(task.id);
return `
${items.length === 0
? '
История пока пустая
'
: items.map(item => `
${this.esc(item.message)}
${this.esc(item.author_name || 'Система')} · ${this.esc(this.formatDate(item.created_at))}
`).join('')}
`;
},
renderBugReportSection(task) {
if (!task?.id) return '';
const report = this.bugReportForTask(task.id);
if (!report) return '';
const prompt = this.bugPromptText(report);
const route = report.page_route || report.page_url || '—';
return `
${this.esc(report.title || task.title || 'Баг-репорт')}
${this.esc(report.section_name || 'Без раздела')}
•
${this.esc(report.subsection_name || 'Без подраздела')}
•
${this.esc(route)}
${this.esc(this.bugSeverityLabel(report.severity))}
${this.esc(this.bugPromptStatusLabel(report.codex_status, !!prompt))}
${report.expected_result ? `
` : ''}
${report.steps_to_reproduce ? `
` : ''}
${prompt
? `
${this.esc(prompt)}`
: '
Prompt ещё не сгенерировался.
'}
`;
},
renderEditor(task) {
if (!task) return '';
const isNew = !task.id;
const watcherIds = this.editorWatcherIds(task);
const visibleContexts = this.visibleContextsForTask(task);
const restoredDraftNotice = this.createDraft && this.editorUi?.restoredDraft;
const saveState = this._saveState || {};
const saveActive = !!saveState.active;
const saveProgress = Number.isFinite(saveState.progress) ? Math.max(0, Math.min(100, saveState.progress)) : 0;
return `
${restoredDraftNotice ? `
Черновик восстановлен. Эта версия хранится только локально в вашем браузере и скоро истечет сама.
` : ''}
${this.esc(saveState.message || 'Сохраняем задачу…')}
${saveProgress}%
${this.contextSelectHtml({
key: 'order',
label: 'Заказ',
id: 'task-editor-order',
optionsHtml: this.orderOptionsHtml(task.order_id),
visible: visibleContexts.order,
})}
${this.contextSelectHtml({
key: 'project',
label: 'Проект',
id: 'task-editor-project',
optionsHtml: this.projectOptionsHtml(task.project_id),
visible: visibleContexts.project,
})}
${this.contextSelectHtml({
key: 'china',
label: 'China',
id: 'task-editor-china',
optionsHtml: this.chinaOptionsHtml(task.china_purchase_id),
visible: visibleContexts.china,
})}
${this.contextSelectHtml({
key: 'warehouse',
label: 'Склад',
id: 'task-editor-warehouse',
optionsHtml: this.warehouseOptionsHtml(task.warehouse_item_id),
visible: visibleContexts.warehouse,
})}
${!isNew ? `` : ''}
${!isNew ? `` : ''}
${!isNew ? `` : ''}
${!isNew ? `` : ''}
${this.renderBugReportSection(task)}
${this.renderChecklistSection(task)}
${this.renderSubtasksSection(task)}
${this.renderCommentsSection(task)}
${this.renderAssetsSection(task)}
${this.renderActivitySection(task)}
`;
},
render() {
const container = document.getElementById('page-tasks');
if (!container) return;
if (this.isLoading && !this.bundle) {
container.innerHTML = `
📝
Загружаем рабочий центр задач…
`;
return;
}
const tasks = this.filteredTasks();
const activeTask = this.editorTask();
const hasActiveFilters = this.filters.status || this.filters.priority || this.filters.assignee_id || this.filters.reporter_id || this.filters.project_id || this.filters.order_id || this.filters.area_id || this.filters.due || this.filters.mine || this.filters.awaiting_review || this.filters.waiting_only;
container.innerHTML = `
${this.statsCardsHtml()}
${this.filtersHtml()}
${this.renderMainContent(tasks)}
`;
this.renderDrawer(activeTask);
this.renderTemplateManager();
},
renderDrawer(task) {
let drawer = document.getElementById('task-drawer-overlay');
if (!task) {
if (drawer) {
drawer.classList.remove('is-open');
document.body.style.overflow = '';
setTimeout(() => drawer.remove(), 250);
}
return;
}
if (!drawer) {
drawer = document.createElement('div');
drawer.id = 'task-drawer-overlay';
drawer.className = 'task-drawer-overlay';
drawer.innerHTML = `
`;
document.body.appendChild(drawer);
document.body.style.overflow = 'hidden';
requestAnimationFrame(() => drawer.classList.add('is-open'));
if (!this._escHandler) {
this._escHandler = (e) => { if (e.key === 'Escape') Tasks.cancelEditor(); };
document.addEventListener('keydown', this._escHandler);
}
} else {
if (!drawer.classList.contains('is-open')) {
document.body.style.overflow = 'hidden';
requestAnimationFrame(() => drawer.classList.add('is-open'));
}
}
const content = drawer.querySelector('.task-drawer-content');
if (content) {
content.innerHTML = this.renderEditor(task);
this.bindEditorListeners(content);
}
},
bindEditorListeners(container) {
if (!container || !this.createDraft) return;
const fields = container.querySelectorAll('input, textarea, select');
fields.forEach(field => {
const handler = () => this.captureLocalDraftFromEditor();
field.addEventListener('input', handler);
field.addEventListener('change', handler);
});
},
captureLocalDraftFromEditor() {
if (!this.createDraft) return;
clearTimeout(this._draftSaveTimer);
this._draftSaveTimer = setTimeout(() => {
const draft = this.readEditorForm();
this.createDraft = {
...this.createDraft,
...draft,
};
this.persistStoredDraft(this.createDraft);
}, 250);
},
toggleContextBinding(kind) {
if (!this.editorUi?.visibleContexts) {
this.editorUi = {
...(this.editorUi || {}),
visibleContexts: { order: false, project: false, china: false, warehouse: false },
};
}
const nextVisible = !this.editorUi.visibleContexts[kind];
this.editorUi.visibleContexts[kind] = nextVisible;
const button = document.querySelector(`.task-context-toggle[data-context-key="${kind}"]`);
if (button) {
const baseLabel = button.getAttribute('data-label') || button.textContent.replace(/^(\+|✓)\s*/, '');
button.classList.toggle('active', nextVisible);
button.textContent = `${nextVisible ? '✓ ' : '+ '}${baseLabel}`;
}
const fieldWrapper = document.querySelector(`.task-context-field[data-context-key="${kind}"]`);
if (fieldWrapper) fieldWrapper.classList.toggle('is-hidden', !nextVisible);
const fieldMap = {
order: 'task-editor-order',
project: 'task-editor-project',
china: 'task-editor-china',
warehouse: 'task-editor-warehouse',
};
const draftFieldMap = {
order: 'order_id',
project: 'project_id',
china: 'china_purchase_id',
warehouse: 'warehouse_item_id',
};
if (!nextVisible) {
const input = document.getElementById(fieldMap[kind]);
if (input) input.value = '';
if (this.createDraft) this.createDraft[draftFieldMap[kind]] = '';
}
this.captureLocalDraftFromEditor();
},
renderTemplateManager() {
let drawer = document.getElementById('task-template-overlay');
if (!this.templateManagerOpen) {
if (drawer) {
drawer.classList.remove('is-open');
setTimeout(() => drawer.remove(), 250);
}
return;
}
if (!drawer) {
drawer = document.createElement('div');
drawer.id = 'task-template-overlay';
drawer.className = 'task-drawer-overlay task-template-overlay';
drawer.innerHTML = `
`;
document.body.appendChild(drawer);
requestAnimationFrame(() => drawer.classList.add('is-open'));
} else if (!drawer.classList.contains('is-open')) {
requestAnimationFrame(() => drawer.classList.add('is-open'));
}
const content = drawer.querySelector('.task-drawer-content');
const templates = (this.bundle?.templates || []).filter(item => item.kind === 'task');
if (content) {
content.innerHTML = `
${templates.map(item => `
`).join('') || '
Шаблонов пока нет
'}
`;
}
},
selectTemplateDraft(templateId) {
const template = (this.bundle?.templates || []).find(item => item.kind === 'task' && String(item.id) === String(templateId));
this.resetTemplateDraft(template || null);
this.renderTemplateManager();
},
readTemplateForm() {
return {
id: this.templateDraft?.id || '',
name: document.getElementById('task-template-name')?.value.trim() || '',
title: document.getElementById('task-template-title')?.value.trim() || '',
description: document.getElementById('task-template-description')?.value.trim() || '',
default_priority: document.getElementById('task-template-priority')?.value || 'normal',
checklist_items: document.getElementById('task-template-checklist')?.value || '',
suggested_subtasks: document.getElementById('task-template-subtasks')?.value || '',
};
},
async saveTemplate() {
const draft = this.readTemplateForm();
const saved = await saveWorkTemplate(draft);
await this.refreshData();
this.resetTemplateDraft(saved);
if (this.createDraft) {
this.createDraft.template_id = saved.id;
this.editorUi.restoredDraft = false;
}
App.toast(draft.id ? 'Шаблон обновлен' : 'Шаблон создан');
this.render();
},
async deleteTemplate(templateId) {
if (!confirm('Удалить шаблон задачи?')) return;
await deleteWorkTemplate(templateId);
await this.refreshData();
this.resetTemplateDraft(null);
if (this.createDraft && String(this.createDraft.template_id || '') === String(templateId)) {
this.createDraft.template_id = '';
}
App.toast('Шаблон удален');
this.render();
},
discardStoredDraft() {
this.clearStoredDraft();
this.editorUi.restoredDraft = false;
if (this.createDraft) {
const preserved = {
reporter_id: App.currentEmployeeId || '',
status: 'incoming',
priority: this.createDraft.priority || 'normal',
};
this.openCreate(preserved, this.createDraft.template_id || null);
}
},
defaultTaskTemplateId() {
return (this.bundle?.templates || []).find(item => item.kind === 'task')?.id || '';
},
shiftCalendar(delta) {
const [year, month] = this.calendarMonth.split('-').map(value => Number(value));
const next = new Date(year, month - 1 + delta, 1);
this.calendarMonth = next.toISOString().slice(0, 7);
this.render();
},
readEditorForm() {
const watcherIds = Array.from(document.querySelectorAll('.wm-watcher-chip input:checked')).map(input => Number(input.value));
const existing = this.currentTaskId ? this.taskById(this.currentTaskId) : null;
const orderId = document.getElementById('task-editor-order')?.value || '';
const projectId = document.getElementById('task-editor-project')?.value || '';
const chinaPurchaseId = document.getElementById('task-editor-china')?.value || '';
const warehouseItemId = document.getElementById('task-editor-warehouse')?.value || '';
const areaId = this.inferHiddenAreaId({
...(this.createDraft || existing || {}),
order_id: orderId,
project_id: projectId,
china_purchase_id: chinaPurchaseId,
warehouse_item_id: warehouseItemId,
});
return {
id: document.getElementById('task-editor-id')?.value || '',
template_id: document.getElementById('task-editor-template')?.value || '',
title: document.getElementById('task-editor-title')?.value.trim() || '',
status: document.getElementById('task-editor-status')?.value || 'incoming',
priority: document.getElementById('task-editor-priority')?.value || 'normal',
due_date: document.getElementById('task-editor-due-date')?.value || '',
due_time: document.getElementById('task-editor-due-time')?.value || '',
reporter_id: document.getElementById('task-editor-reporter')?.value || '',
assignee_id: document.getElementById('task-editor-assignee')?.value || '',
reviewer_id: watcherIds[0] || '',
primary_context_kind: this.inferPrimaryContextKind({
order_id: orderId,
project_id: projectId,
}),
order_id: orderId,
project_id: projectId,
area_id: areaId || '',
china_purchase_id: chinaPurchaseId,
warehouse_item_id: warehouseItemId,
description: document.getElementById('task-editor-description')?.value.trim() || '',
waiting_for_text: document.getElementById('task-editor-waiting')?.value.trim() || '',
watcher_ids: watcherIds,
parent_task_id: this.createDraft?.parent_task_id || this.taskById(this.currentTaskId)?.parent_task_id || '',
};
},
async paintSaveState() {
await new Promise(resolve => setTimeout(resolve, 0));
const drawer = document.getElementById('task-drawer-overlay');
if (!drawer) return;
const saveState = this._saveState || {};
const saveActive = !!saveState.active;
drawer.classList.toggle('is-saving', saveActive);
const progressBox = drawer.querySelector('#task-save-progress');
if (progressBox) {
progressBox.classList.toggle('is-active', saveActive);
const strong = progressBox.querySelector('strong');
const percent = progressBox.querySelector('.task-editor-save-progress-row span');
const fill = progressBox.querySelector('.task-editor-save-progress-bar span');
const value = Number.isFinite(saveState.progress) ? Math.max(0, Math.min(100, saveState.progress)) : 0;
if (strong) strong.textContent = saveState.message || 'Сохраняем задачу…';
if (percent) percent.textContent = `${value}%`;
if (fill) fill.style.width = `${value}%`;
}
const saveButton = drawer.querySelector('#task-save-button');
if (saveButton) {
saveButton.disabled = saveActive;
saveButton.textContent = saveActive ? 'Сохраняем…' : 'Сохранить';
}
drawer.querySelectorAll('.task-editor-actions .btn, .card-header .btn').forEach(button => {
if (!button) return;
if (button.id === 'task-save-button') return;
button.disabled = saveActive;
});
},
async updateSaveState(message, progress) {
this._saveState = {
active: true,
message: message || 'Сохраняем задачу…',
progress: Number.isFinite(progress) ? progress : 0,
};
await this.paintSaveState();
},
async clearSaveState() {
this._saveState = null;
await this.paintSaveState();
},
async saveTask() {
if (this._saveState?.active) return;
const draft = this.readEditorForm();
const existing = draft.id ? this.taskById(draft.id) : null;
if (!draft.title) {
App.toast('Введите название задачи');
return;
}
if (!draft.assignee_id && !existing?.assignee_id) {
App.toast('Укажите исполнителя');
return;
}
if (!draft.due_date && !existing?.due_date) {
App.toast('Укажите дедлайн');
return;
}
try {
await this.updateSaveState('Сохраняем задачу…', 15);
const previousOverdue = existing ? this.isOverdue(existing) : false;
const saved = await saveWorkTask(draft, {
actor_id: App.currentEmployeeId,
actor_name: App.getCurrentEmployeeName(),
});
await this.updateSaveState('Сохраняем наблюдателей…', 40);
await saveTaskWatchers(saved.id, draft.watcher_ids);
const template = draft.template_id
? (this.bundle?.templates || []).find(item => String(item.id) === String(draft.template_id))
: null;
if (!existing && template) {
await this.updateSaveState('Добавляем элементы шаблона…', 60);
await this.applyTaskTemplateArtifacts(saved, template);
}
await this.updateSaveState('Отправляем уведомления…', 80);
await this.emitTaskEvents(saved, existing, previousOverdue, { watcherUserIds: draft.watcher_ids });
await this.updateSaveState('Обновляем список задач…', 95);
await this.refreshData();
this.createDraft = null;
this.clearStoredDraft();
this.editorUi.restoredDraft = false;
this.currentTaskId = saved.id;
await this.clearSaveState();
App.toast(existing ? 'Задача обновлена' : 'Задача создана');
this.render();
} catch (error) {
console.error('Tasks save error:', error);
await this.clearSaveState();
App.toast('Не получилось сохранить задачу');
}
},
async emitTaskEvents(saved, existing, previousOverdue, options = {}) {
const watcherUserIds = Array.isArray(options.watcherUserIds)
? options.watcherUserIds
: this.watcherIdsForTask(saved.id);
if (!existing || String(existing.assignee_id || '') !== String(saved.assignee_id || '')) {
if (saved.assignee_id) {
await TaskEvents.emit('task_assigned', {
task_id: saved.id,
project_id: saved.project_id || null,
assignee_id: saved.assignee_id,
});
}
}
if (existing && existing.status !== saved.status) {
await TaskEvents.emit('task_status_changed', {
task_id: saved.id,
project_id: saved.project_id || null,
old_status: existing.status || '',
new_status: saved.status || '',
watcher_user_ids: watcherUserIds,
});
}
if (saved.status === 'review' && existing?.status !== 'review') {
await TaskEvents.emit('task_sent_to_review', {
task_id: saved.id,
project_id: saved.project_id || null,
reviewer_id: saved.reviewer_id || null,
watcher_user_ids: saved.reviewer_id ? [] : watcherUserIds,
});
}
const currentOverdue = this.isOverdue(saved);
if (previousOverdue !== currentOverdue) {
await TaskEvents.emit('task_overdue_state_changed', {
task_id: saved.id,
project_id: saved.project_id || null,
is_overdue: currentOverdue,
});
}
if (saved.due_date && !currentOverdue && saved.status !== 'done' && saved.status !== 'cancelled') {
const dueAt = new Date(WorkManagementCore.buildTaskDueIso(saved));
const diffMs = dueAt.getTime() - Date.now();
if (diffMs > 0 && diffMs <= 24 * 60 * 60 * 1000) {
await TaskEvents.emit('task_due_soon', {
task_id: saved.id,
project_id: saved.project_id || null,
due_date: saved.due_date,
due_time: saved.due_time || null,
});
}
}
},
async applyTaskTemplateArtifacts(task, template) {
for (const [index, title] of (template.checklist_items || []).entries()) {
await saveTaskChecklistItem({
task_id: task.id,
title,
sort_index: (index + 1) * 100,
});
}
for (const [index, title] of (template.suggested_subtasks || []).entries()) {
await saveWorkTask({
title,
description: '',
status: 'incoming',
priority: template.default_priority || task.priority || 'normal',
reporter_id: task.reporter_id || App.currentEmployeeId,
reporter_name: task.reporter_name || App.getCurrentEmployeeName(),
assignee_id: task.assignee_id || App.currentEmployeeId,
assignee_name: task.assignee_name || App.getCurrentEmployeeName(),
reviewer_id: task.reviewer_id || null,
reviewer_name: task.reviewer_name || '',
area_id: task.area_id || template.suggested_area_id || this.inferHiddenAreaId(task) || null,
order_id: task.order_id || null,
project_id: task.project_id || null,
primary_context_kind: task.primary_context_kind || 'area',
due_date: task.due_date || this.todayYmd(),
due_time: task.due_time || null,
parent_task_id: task.id,
sort_index: (index + 1) * 100,
}, {
actor_id: App.currentEmployeeId,
actor_name: App.getCurrentEmployeeName(),
});
}
},
async addChecklistItem(taskId) {
const input = document.getElementById('task-checklist-new');
const title = input?.value.trim() || '';
if (!title) {
App.toast('Введите текст пункта');
return;
}
await saveTaskChecklistItem({ task_id: taskId, title });
await this.refreshData();
this.currentTaskId = Number(taskId);
this.render();
},
async toggleChecklist(itemId, checked) {
const item = (this.bundle?.checklistItems || []).find(entry => String(entry.id) === String(itemId));
if (!item) return;
await saveTaskChecklistItem({ ...item, is_done: checked });
await this.refreshData();
this.render();
},
async deleteChecklistItem(itemId) {
await deleteTaskChecklistItem(itemId);
await this.refreshData();
this.render();
},
async addComment(taskId) {
const textarea = document.getElementById('task-comment-new');
const body = textarea?.value.trim() || '';
if (!body) {
App.toast('Введите комментарий');
return;
}
const comment = await saveTaskComment({
task_id: taskId,
author_id: App.currentEmployeeId,
author_name: App.getCurrentEmployeeName(),
body,
});
if ((comment.mentions || []).length > 0) {
await TaskEvents.emit('task_mentioned', {
task_id: taskId,
project_id: this.taskById(taskId)?.project_id || null,
mention_user_ids: comment.mentions,
comment_id: comment.id,
});
}
await this.refreshData();
this.currentTaskId = Number(taskId);
this.render();
},
async addLink(taskId) {
const url = document.getElementById('task-link-url')?.value.trim() || '';
const title = document.getElementById('task-link-title')?.value.trim() || '';
if (!url) {
App.toast('Укажите ссылку');
return;
}
const task = this.taskById(taskId);
await saveWorkAsset({
task_id: taskId,
project_id: task?.project_id || null,
kind: 'link',
title,
url,
created_by: App.currentEmployeeId,
created_by_name: App.getCurrentEmployeeName(),
});
await this.refreshData();
this.currentTaskId = Number(taskId);
this.render();
},
async addFile(taskId) {
const input = document.getElementById('task-file-input');
const file = input?.files?.[0];
if (!file) {
App.toast('Выберите файл');
return;
}
if (file.size > 3 * 1024 * 1024) {
App.toast('Файл слишком большой. Максимум 3 МБ');
return;
}
const dataUrl = await this.readFileAsDataUrl(file);
const task = this.taskById(taskId);
await saveWorkAsset({
task_id: taskId,
project_id: task?.project_id || null,
kind: 'file',
title: document.getElementById('task-file-title')?.value.trim() || file.name,
file_name: file.name,
file_type: file.type || '',
file_size: file.size || 0,
data_url: dataUrl,
url: dataUrl,
created_by: App.currentEmployeeId,
created_by_name: App.getCurrentEmployeeName(),
});
await this.refreshData();
this.currentTaskId = Number(taskId);
this.render();
},
async deleteAsset(assetId) {
if (!confirm('Удалить файл или ссылку?')) return;
await deleteWorkAsset(assetId);
await this.refreshData();
this.render();
},
async changeStatus(taskId, status, options = {}) {
const task = this.taskById(taskId);
if (!task) return;
const previousOverdue = this.isOverdue(task);
const preserveSelection = options.preserveSelection ?? (this.currentTaskId === Number(taskId));
const nextCurrentTaskId = preserveSelection
? Number(taskId)
: (this.currentTaskId === Number(taskId) ? null : this.currentTaskId);
const saved = await saveWorkTask({ ...task, status }, {
actor_id: App.currentEmployeeId,
actor_name: App.getCurrentEmployeeName(),
});
await this.emitTaskEvents(saved, task, previousOverdue, options);
await this.refreshData();
this.currentTaskId = nextCurrentTaskId;
this.render();
},
async sendToReview(taskId) {
const task = this.taskById(taskId);
if (!task) return;
const watcherIds = this.currentTaskId === Number(taskId)
? Array.from(document.querySelectorAll('.wm-watcher-chip input:checked')).map(input => Number(input.value))
: this.watcherIdsForTask(taskId);
if (!watcherIds.length) {
App.toast('Добавьте наблюдателя перед согласованием');
return;
}
if (this.currentTaskId === Number(taskId)) {
await saveTaskWatchers(taskId, watcherIds);
}
await this.changeStatus(taskId, 'review', { watcherUserIds: watcherIds });
},
async returnToWork(taskId) {
await this.changeStatus(taskId, 'in_progress');
},
async approveTask(taskId) {
await this.changeStatus(taskId, 'done');
},
onInlineStatusClick(event) {
event?.stopPropagation?.();
return false;
},
onTaskRowClick(event, taskId) {
const target = event?.target;
if (target && typeof target.closest === 'function') {
const interactiveSelector = '[data-task-row-ignore], button, select, input, textarea, a, label, summary, details';
if (target.closest(interactiveSelector)) {
return false;
}
}
this.openTask(taskId);
return true;
},
onInlineStatusChange(event, taskId, value) {
event?.stopPropagation?.();
const select = event?.currentTarget || event?.target;
if (select && value) {
select.className = 'inline-status-select status-' + value;
}
return this.changeStatus(taskId, value, { preserveSelection: this.currentTaskId === Number(taskId) });
},
onDeleteTaskClick(event, taskId) {
event?.preventDefault?.();
event?.stopPropagation?.();
void this.deleteTask(taskId);
return false;
},
onMoveTaskClick(event, taskId, direction) {
event?.preventDefault?.();
event?.stopPropagation?.();
void this.moveTask(taskId, direction);
return false;
},
async deleteTask(taskId) {
if (!confirm('Удалить задачу со всеми комментариями и файлами?')) return;
const deletingIds = this.collectTaskTreeIds(taskId);
deletingIds.forEach(id => {
this._deletingTaskIds[String(id)] = true;
});
if (deletingIds.includes(Number(this.currentTaskId))) {
this.currentTaskId = null;
}
this.render();
try {
await deleteWorkTask(taskId);
this.hydrateFromCache();
} catch (error) {
console.error('deleteTask error:', error);
App.toast('Не удалось удалить задачу');
} finally {
deletingIds.forEach(id => {
delete this._deletingTaskIds[String(id)];
});
this.render();
}
},
copyBugPrompt(taskId) {
const report = this.bugReportForTask(taskId);
const prompt = this.bugPromptText(report);
if (!prompt) {
App.toast('Prompt пока не готов');
return;
}
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
App.toast('Не удалось скопировать prompt');
return;
}
navigator.clipboard.writeText(prompt)
.then(() => App.toast('Prompt скопирован'))
.catch(() => App.toast('Не удалось скопировать prompt'));
},
async moveTask(taskId, direction) {
const task = this.taskById(taskId);
if (!task) return;
const queue = this.filteredTasks().filter(item =>
String(item.assignee_id || '') === String(task.assignee_id || '')
&& String(item.parent_task_id || '') === String(task.parent_task_id || '')
);
const idx = queue.findIndex(item => String(item.id) === String(task.id));
const swapWith = queue[idx + direction];
if (!swapWith) return;
await saveWorkTask({ ...task, sort_index: swapWith.sort_index }, {
actor_id: App.currentEmployeeId,
actor_name: App.getCurrentEmployeeName(),
skipActivity: true,
});
await saveWorkTask({ ...swapWith, sort_index: task.sort_index }, {
actor_id: App.currentEmployeeId,
actor_name: App.getCurrentEmployeeName(),
skipActivity: true,
});
await this.refreshData();
this.render();
},
async onTemplateChange(templateId) {
if (!this.createDraft) return;
const template = (this.bundle?.templates || []).find(item => String(item.id) === String(templateId));
if (!template) return;
this.createDraft = {
...this.createDraft,
template_id: template.id,
title: template.title || this.createDraft.title,
description: template.description || this.createDraft.description,
priority: template.default_priority || this.createDraft.priority,
area_id: template.suggested_area_id || this.createDraft.area_id,
};
this.persistStoredDraft(this.createDraft);
this.render();
},
readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = event => resolve(event.target?.result || '');
reader.onerror = () => reject(new Error('Не удалось прочитать файл'));
reader.readAsDataURL(file);
});
},
};