diff --git a/frontend/src/PrototypeUI.jsx b/frontend/src/PrototypeUI.jsx index 0c5feba..69cabb9 100644 --- a/frontend/src/PrototypeUI.jsx +++ b/frontend/src/PrototypeUI.jsx @@ -609,6 +609,9 @@ function PageRouter({ onUpdateProject={onUpdateProject} onDeleteProject={onDeleteProject} onUpdateProjectTemplates={onUpdateProjectTemplates} + onUpdateTemplate={onUpdateTemplate} + onDeleteTemplate={onDeleteTemplate} + onFetchTemplateImpact={fetchTemplateImpact} onAddProjectScope={onAddProjectScope} onRemoveProjectScope={onRemoveProjectScope} onCreateOverrideRequest={onCreateOverrideRequest} diff --git a/frontend/src/pages/Projects.jsx b/frontend/src/pages/Projects.jsx index 3837edc..d5e038b 100644 --- a/frontend/src/pages/Projects.jsx +++ b/frontend/src/pages/Projects.jsx @@ -24,6 +24,7 @@ import { fetchProjectScopeConfig } from '../api/projects.js'; import { configRows } from '../data/configs.js'; import { formatConfigContent, formatParsedContent } from '../lib/formatConfigContent.js'; import { overridesAtScope } from '../lib/scope.js'; +import { TemplateChangeRequestModal, TemplateDetailModal, TemplateFormModal } from './Templates.jsx'; import scrollbar from '../styles/scrollbar.module.css'; function inheritedAtScope(project) { @@ -52,6 +53,9 @@ export function Projects({ onUpdateProject, onDeleteProject, onUpdateProjectTemplates, + onUpdateTemplate, + onDeleteTemplate, + onFetchTemplateImpact, onAddProjectScope, onRemoveProjectScope, onCreateOverrideRequest, @@ -73,6 +77,14 @@ export function Projects({ const [scopeConfigError, setScopeConfigError] = useState(null); const [cloneDraft, setCloneDraft] = useState(null); const [viewingTemplate, setViewingTemplate] = useState(null); + const [templateActiveTab, setTemplateActiveTab] = useState('definition'); + const [editingTemplate, setEditingTemplate] = useState(null); + const [templateChangeRequestDraft, setTemplateChangeRequestDraft] = useState(null); + const [templateActionError, setTemplateActionError] = useState(null); + const [deletingTemplateId, setDeletingTemplateId] = useState(null); + const [impactByTemplateId, setImpactByTemplateId] = useState({}); + const [impactLoadingId, setImpactLoadingId] = useState(null); + const [impactErrorByTemplateId, setImpactErrorByTemplateId] = useState({}); const [editingProject, setEditingProject] = useState(null); const [deletingProject, setDeletingProject] = useState(null); const [editingTemplatesFor, setEditingTemplatesFor] = useState(null); @@ -102,6 +114,38 @@ export function Projects({ const selectedScope = factoryScopes.find( (item) => item.siteName === selectedSiteName && item.environmentName === selectedEnvName, ) ?? null; + const selectedProjectTemplate = viewingTemplate + ? normalizeProjectTemplateForModal(viewingTemplate, templates) + : null; + + useEffect(() => { + if (!selectedProjectTemplate?.id || templateActiveTab !== 'impact' || !onFetchTemplateImpact) return; + if (impactByTemplateId[selectedProjectTemplate.id]) return; + + let cancelled = false; + setImpactLoadingId(selectedProjectTemplate.id); + setImpactErrorByTemplateId((current) => ({ ...current, [selectedProjectTemplate.id]: null })); + + onFetchTemplateImpact(selectedProjectTemplate.id, token) + .then((impact) => { + if (cancelled) return; + setImpactByTemplateId((current) => ({ ...current, [selectedProjectTemplate.id]: impact })); + }) + .catch((error) => { + if (cancelled) return; + setImpactErrorByTemplateId((current) => ({ + ...current, + [selectedProjectTemplate.id]: error.message ?? 'Failed to load template impact.', + })); + }) + .finally(() => { + if (!cancelled) setImpactLoadingId(null); + }); + + return () => { + cancelled = true; + }; + }, [impactByTemplateId, onFetchTemplateImpact, selectedProjectTemplate?.id, templateActiveTab, token]); useEffect(() => { if (selectedProjectName && !projects.some((project) => project.name === selectedProjectName)) { @@ -287,6 +331,64 @@ export function Projects({ setShowClone(false); }; + const handleOpenProjectTemplate = (template) => { + setViewingTemplate(template); + setTemplateActiveTab('definition'); + setTemplateActionError(null); + }; + + const handleEditProjectTemplate = () => { + if (!selectedProjectTemplate) return; + setTemplateActionError(null); + setEditingTemplate(selectedProjectTemplate); + }; + + const handleDeleteProjectTemplate = () => { + if (!selectedProjectTemplate || !onDeleteTemplate || deletingTemplateId) return; + setTemplateActionError(null); + setTemplateChangeRequestDraft({ + action: 'delete', + template: selectedProjectTemplate, + payload: null, + }); + }; + + const handleSubmitTemplateEdit = async (templateUpdate) => { + setTemplateActionError(null); + setEditingTemplate(null); + setTemplateChangeRequestDraft({ + action: 'update', + template: selectedProjectTemplate, + payload: templateUpdate, + }); + return templateUpdate; + }; + + const submitTemplateChangeRequest = async ({ reason }) => { + if (!templateChangeRequestDraft) return null; + const { action, template, payload } = templateChangeRequestDraft; + setTemplateActionError(null); + if (action === 'delete') { + setDeletingTemplateId(template.id); + } + + try { + const result = action === 'delete' + ? await onDeleteTemplate(template, reason) + : await onUpdateTemplate({ ...payload, reason }); + setTemplateChangeRequestDraft(null); + setViewingTemplate(null); + return result; + } catch (error) { + setTemplateActionError(error.message ?? 'Failed to open template change request.'); + throw error; + } finally { + if (action === 'delete') { + setDeletingTemplateId(null); + } + } + }; + return (