const Projects = { bundle: null, employees: [], orders: [], currentProjectId: null, createDraft: null, listFilters: { search: '', type: '', owner_id: '', status: '', area_id: '', order_id: '', }, async load(projectId) { this.currentProjectId = projectId ? Number(projectId) : null; await this.refreshData(); this.render(); }, async refreshData() { this.bundle = await loadWorkBundle(); this.employees = await loadEmployees(); this.orders = await loadOrders({}); }, esc(value) { return String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }, todayYmd() { return new Date().toISOString().slice(0, 10); }, formatDate(value) { if (!value) return '—'; try { return new Date(value).toLocaleDateString('ru-RU'); } catch (error) { return value; } }, employeeNameById(id, fallback) { const employee = (this.employees || []).find(item => String(item.id) === String(id)); return employee?.name || fallback || '—'; }, areaName(areaId) { return (this.bundle?.areas || []).find(item => String(item.id) === String(areaId))?.name || '—'; }, orderName(orderId, fallback) { return (this.orders || []).find(item => String(item.id) === String(orderId))?.order_name || fallback || '—'; }, projectById(projectId) { return (this.bundle?.projects || []).find(item => String(item.id) === String(projectId)) || null; }, tasksForProject(projectId) { return (this.bundle?.tasks || []).filter(item => String(item.project_id) === String(projectId)); }, assetsForProject(projectId) { return (this.bundle?.assets || []).filter(item => String(item.project_id) === String(projectId) && !item.task_id ); }, activityForProject(projectId) { return (this.bundle?.activity || []).filter(item => String(item.project_id) === String(projectId)); }, openCreate(preset = {}, templateId = null) { const template = templateId ? (this.bundle?.templates || []).find(item => String(item.id) === String(templateId)) : null; this.currentProjectId = null; this.createDraft = { id: '', template_id: template?.id || '', title: preset.title || template?.title || '', type: preset.type || template?.project_type || 'Другое', owner_id: preset.owner_id || App.currentEmployeeId || '', linked_order_id: preset.linked_order_id || '', area_id: preset.area_id || template?.suggested_area_id || '', start_date: preset.start_date || this.todayYmd(), due_date: preset.due_date || '', launch_at: preset.launch_at || '', status: preset.status || 'active', brief: preset.brief || template?.description || '', goal: preset.goal || '', result_summary: preset.result_summary || '', }; if (App.currentPage !== 'projects') { App.navigate('projects'); return; } this.render(); }, cancelCreate() { this.createDraft = null; this.render(); }, updateListFilter(field, value) { this.listFilters[field] = value || ''; this.render(); }, buildProjectTableRows(projects) { return projects.map(project => { const taskCount = this.tasksForProject(project.id).length; const statusLabel = WorkManagementCore.getProjectStatusLabel(project.status); return ` ${this.esc(project.title)} ${this.esc(project.type || '—')} ${this.esc(statusLabel)} ${this.esc(this.employeeNameById(project.owner_id, project.owner_name))} ${this.esc(this.orderName(project.linked_order_id, project.linked_order_name))} ${this.esc(this.areaName(project.area_id))} ${taskCount} ${this.esc(project.due_date ? this.formatDate(project.due_date) : '—')} `; }).join(''); }, filteredProjects() { const search = WorkManagementCore.normalizeText(this.listFilters.search); return (this.bundle?.projects || []).filter(project => { if (this.listFilters.type && project.type !== this.listFilters.type) return false; if (this.listFilters.owner_id && String(project.owner_id || '') !== String(this.listFilters.owner_id)) return false; if (this.listFilters.status && project.status !== this.listFilters.status) return false; if (this.listFilters.area_id && String(project.area_id || '') !== String(this.listFilters.area_id)) return false; if (this.listFilters.order_id && String(project.linked_order_id || '') !== String(this.listFilters.order_id)) return false; if (!search) return true; const projectTasks = (this.bundle?.tasks || []).filter(item => String(item.project_id || '') === String(project.id)); const commentText = projectTasks .flatMap(task => (this.bundle?.comments || []).filter(comment => String(comment.task_id) === String(task.id)).map(comment => comment.body || '')) .join(' '); const text = WorkManagementCore.normalizeText([ project.title, project.type, project.brief, project.goal, this.orderName(project.linked_order_id, project.linked_order_name), this.employeeNameById(project.owner_id, project.owner_name), projectTasks.map(item => `${item.title} ${item.description || ''}`).join(' '), commentText, ].join(' ')); return text.includes(search); }); }, renderCreateForm() { if (!this.createDraft) return ''; const templateOptions = (this.bundle?.templates || []) .filter(item => item.kind === 'project') .map(item => ``) .join(''); const ownerOptions = (this.employees || []) .filter(item => item.is_active !== false) .map(item => ``) .join(''); const areaOptions = (this.bundle?.areas || []) .map(item => ``) .join(''); const orderOptions = (this.orders || []) .filter(item => item.status !== 'deleted') .map(item => ``) .join(''); const projectTypeOptions = WorkManagementCore.PROJECT_TYPE_OPTIONS .map(item => ``) .join(''); const statusOptions = WorkManagementCore.PROJECT_STATUS_OPTIONS .map(item => ``) .join(''); return `

Новый проект

`; }, renderListPage() { const rows = this.filteredProjects(); const ownerFilterOptions = (this.employees || []) .filter(item => item.is_active !== false) .map(item => ``) .join(''); const areaFilterOptions = (this.bundle?.areas || []) .map(item => ``) .join(''); const orderFilterOptions = (this.orders || []) .filter(item => item.status !== 'deleted') .map(item => ``) .join(''); const typeOptions = WorkManagementCore.PROJECT_TYPE_OPTIONS .map(item => ``) .join(''); const statusOptions = WorkManagementCore.PROJECT_STATUS_OPTIONS .map(item => ``) .join(''); return `
Всего
${(this.bundle?.projects || []).length}
Активные
${(this.bundle?.projects || []).filter(item => item.status === 'active').length}
На паузе
${(this.bundle?.projects || []).filter(item => item.status === 'on_hold').length}
Завершённые
${(this.bundle?.projects || []).filter(item => item.status === 'done').length}
${this.renderCreateForm()}
${rows.length === 0 ? '' : this.buildProjectTableRows(rows)}
Проект Тип Статус Владелец Заказ Направление Задач Дедлайн
Нет проектов по фильтру
`; }, renderAssetsSection(project) { const assets = this.assetsForProject(project.id); const rows = assets.map(asset => { if (asset.kind === 'file') { return `
${this.esc(asset.title || asset.file_name || 'Файл')}
${this.esc(asset.file_name || '')}
Открыть
`; } return `
${this.esc(asset.title || 'Ссылка')}
${this.esc(asset.url || '')}
Открыть
`; }).join(''); return `

Файлы и ссылки

${rows || '
Пока ничего не добавлено
'}
`; }, renderProjectTasksSection(project) { const tasks = this.tasksForProject(project.id) .slice() .sort((a, b) => { const aDue = WorkManagementCore.buildTaskDueIso(a); const bDue = WorkManagementCore.buildTaskDueIso(b); return String(aDue).localeCompare(String(bDue), 'ru'); }); const rows = tasks.map(task => ` ${this.esc(task.title)} ${this.esc(WorkManagementCore.getTaskStatusLabel(task.status))} ${this.esc(task.assignee_name || this.employeeNameById(task.assignee_id, '—'))} ${this.esc(task.priority || 'normal')} ${this.esc(task.due_date ? this.formatDate(task.due_date) : '—')} `).join(''); return `

Задачи

${rows || ''}
Задача Статус Исполнитель Приоритет Дедлайн
Пока нет задач
`; }, renderActivitySection(project) { const rows = this.activityForProject(project.id) .slice() .sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))); return `

Активность

${rows.length === 0 ? '
Пока нет активности
' : rows.map(item => `
${this.esc(item.message)}
${this.esc(item.author_name || 'Система')} · ${this.esc(this.formatDate(item.created_at))}
`).join('')}
`; }, renderDetailPage(project) { if (!project) { return `
📁

Проект не найден

`; } const ownerOptions = (this.employees || []) .filter(item => item.is_active !== false) .map(item => ``) .join(''); const orderOptions = (this.orders || []) .filter(item => item.status !== 'deleted') .map(item => ``) .join(''); const areaOptions = (this.bundle?.areas || []) .map(item => ``) .join(''); const typeOptions = WorkManagementCore.PROJECT_TYPE_OPTIONS .map(item => ``) .join(''); const statusOptions = WorkManagementCore.PROJECT_STATUS_OPTIONS .map(item => ``) .join(''); return `

Карточка проекта

${this.renderProjectTasksSection(project)} ${this.renderAssetsSection(project)} ${this.renderActivitySection(project)} `; }, render() { const container = document.getElementById('page-projects'); if (!container) return; const project = this.currentProjectId ? this.projectById(this.currentProjectId) : null; container.innerHTML = this.currentProjectId ? this.renderDetailPage(project) : this.renderListPage(); }, onCreateTemplateChange(templateId) { const template = (this.bundle?.templates || []).find(item => String(item.id) === String(templateId)); if (!template) { this.createDraft.template_id = ''; this.render(); return; } this.createDraft = { ...this.createDraft, template_id: template.id, title: template.title || this.createDraft.title, type: template.project_type || this.createDraft.type, area_id: template.suggested_area_id || this.createDraft.area_id, brief: template.description || this.createDraft.brief, }; this.render(); }, readCreateForm() { return { template_id: document.getElementById('project-create-template')?.value || '', title: document.getElementById('project-create-title')?.value.trim() || '', type: document.getElementById('project-create-type')?.value || 'Другое', owner_id: document.getElementById('project-create-owner')?.value || '', linked_order_id: document.getElementById('project-create-order')?.value || '', area_id: document.getElementById('project-create-area')?.value || '', start_date: document.getElementById('project-create-start')?.value || '', due_date: document.getElementById('project-create-due')?.value || '', launch_at: document.getElementById('project-create-launch')?.value || '', status: document.getElementById('project-create-status')?.value || 'active', brief: document.getElementById('project-create-brief')?.value.trim() || '', goal: document.getElementById('project-create-goal')?.value.trim() || '', result_summary: document.getElementById('project-create-result')?.value.trim() || '', }; }, readDetailForm() { return { id: document.getElementById('project-detail-id')?.value || '', title: document.getElementById('project-detail-title')?.value.trim() || '', type: document.getElementById('project-detail-type')?.value || 'Другое', owner_id: document.getElementById('project-detail-owner')?.value || '', linked_order_id: document.getElementById('project-detail-order')?.value || '', area_id: document.getElementById('project-detail-area')?.value || '', start_date: document.getElementById('project-detail-start')?.value || '', due_date: document.getElementById('project-detail-due')?.value || '', launch_at: document.getElementById('project-detail-launch')?.value || '', status: document.getElementById('project-detail-status')?.value || 'active', brief: document.getElementById('project-detail-brief')?.value.trim() || '', goal: document.getElementById('project-detail-goal')?.value.trim() || '', result_summary: document.getElementById('project-detail-result')?.value.trim() || '', }; }, async saveCreateForm() { const draft = this.readCreateForm(); if (!draft.title) { App.toast('Введите название проекта'); return; } const isNew = true; const saved = await saveWorkProject(draft, { id: App.currentEmployeeId, name: App.getCurrentEmployeeName(), }); const template = draft.template_id ? (this.bundle?.templates || []).find(item => String(item.id) === String(draft.template_id)) : null; if (isNew && template) { await this.applyProjectTemplateArtifacts(saved, template); } this.createDraft = null; await this.refreshData(); App.toast('Проект сохранён'); App.navigate('projects', true, saved.id); }, async saveDetailForm() { const draft = this.readDetailForm(); if (!draft.title) { App.toast('Введите название проекта'); return; } const saved = await saveWorkProject(draft, { id: App.currentEmployeeId, name: App.getCurrentEmployeeName(), }); await this.refreshData(); this.currentProjectId = saved.id; App.toast('Проект обновлён'); this.render(); }, defaultProjectTemplateId() { return (this.bundle?.templates || []).find(item => item.kind === 'project')?.id || ''; }, async applyProjectTemplateArtifacts(project, template) { const dueDate = project.due_date || project.start_date || this.todayYmd(); const kickoffTask = await saveWorkTask({ title: `Старт проекта: ${project.title}`, description: template.description || '', status: 'planned', priority: template.default_priority || 'normal', reporter_id: App.currentEmployeeId, reporter_name: App.getCurrentEmployeeName(), assignee_id: project.owner_id || App.currentEmployeeId, assignee_name: project.owner_name || App.getCurrentEmployeeName(), area_id: project.area_id || template.suggested_area_id || null, order_id: project.linked_order_id || null, project_id: project.id, primary_context_kind: 'project', due_date: dueDate, due_time: null, }, { actor_id: App.currentEmployeeId, actor_name: App.getCurrentEmployeeName(), }); for (const [index, title] of (template.checklist_items || []).entries()) { await saveTaskChecklistItem({ task_id: kickoffTask.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 || 'normal', reporter_id: App.currentEmployeeId, reporter_name: App.getCurrentEmployeeName(), assignee_id: project.owner_id || App.currentEmployeeId, assignee_name: project.owner_name || App.getCurrentEmployeeName(), area_id: project.area_id || template.suggested_area_id || null, order_id: project.linked_order_id || null, project_id: project.id, parent_task_id: kickoffTask.id, primary_context_kind: 'project', due_date: dueDate, due_time: null, sort_index: (index + 1) * 100, }, { actor_id: App.currentEmployeeId, actor_name: App.getCurrentEmployeeName(), }); } }, async addLink(projectId) { const url = document.getElementById('project-link-url')?.value.trim() || ''; const title = document.getElementById('project-link-title')?.value.trim() || ''; if (!url) { App.toast('Укажите ссылку'); return; } await saveWorkAsset({ project_id: projectId, kind: 'link', title, url, created_by: App.currentEmployeeId, created_by_name: App.getCurrentEmployeeName(), }); await this.refreshData(); this.render(); }, async addFile(projectId) { const input = document.getElementById('project-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); await saveWorkAsset({ project_id: projectId, kind: 'file', title: document.getElementById('project-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.render(); }, async deleteAsset(assetId, projectId) { if (!confirm('Удалить файл или ссылку?')) return; await deleteWorkAsset(assetId); await this.refreshData(); this.currentProjectId = Number(projectId); 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); }); }, };