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);
});
},
};