diff --git a/apps/Administration-FE/scripts/check-contract-drift.mjs b/apps/Administration-FE/scripts/check-contract-drift.mjs index c9a2a3f..b14fde0 100644 --- a/apps/Administration-FE/scripts/check-contract-drift.mjs +++ b/apps/Administration-FE/scripts/check-contract-drift.mjs @@ -19,6 +19,10 @@ const required = [ 'admin-solutions.v1.ts', 'admin-solution-details.v1.ts', 'admin-solution-products.v1.ts', + 'admin-ai-services.v1.ts', + 'admin-cloud-services.v1.ts', + 'admin-data-analytics.v1.ts', + 'admin-digital-transformation.v1.ts', ]; let failed = false; diff --git a/apps/Administration-FE/scripts/check-i18n.mjs b/apps/Administration-FE/scripts/check-i18n.mjs index 9ba5a47..deeda43 100644 --- a/apps/Administration-FE/scripts/check-i18n.mjs +++ b/apps/Administration-FE/scripts/check-i18n.mjs @@ -32,6 +32,16 @@ const files = [ 'components/SolutionProductsList.tsx', 'components/SolutionProductForm.tsx', 'components/SeoFields.tsx', + 'components/NamedPageList.tsx', + 'components/NamedPageForm.tsx', + 'components/AiServicesList.tsx', + 'components/AiServiceForm.tsx', + 'components/CloudServicesList.tsx', + 'components/CloudServiceForm.tsx', + 'components/DataAnalyticsList.tsx', + 'components/DataAnalyticsForm.tsx', + 'components/DigitalTransformationList.tsx', + 'components/DigitalTransformationForm.tsx', 'components/CaseStudiesList.tsx', 'components/CaseStudyForm.tsx', 'components/IndustriesList.tsx', diff --git a/apps/Administration-FE/scripts/generate-client.mjs b/apps/Administration-FE/scripts/generate-client.mjs index fae2919..89dd09a 100644 --- a/apps/Administration-FE/scripts/generate-client.mjs +++ b/apps/Administration-FE/scripts/generate-client.mjs @@ -23,6 +23,13 @@ const solutionProductsContractsDir = join( repoRoot, 'specs/011-admin-solution-products/contracts', ); +const aiServicesContractsDir = join(repoRoot, 'specs/012-admin-ai-services/contracts'); +const cloudServicesContractsDir = join(repoRoot, 'specs/013-admin-cloud-services/contracts'); +const dataAnalyticsContractsDir = join(repoRoot, 'specs/014-admin-data-analytics/contracts'); +const digitalTransformationContractsDir = join( + repoRoot, + 'specs/015-admin-digital-transformation/contracts', +); const contractSets = [ { @@ -67,6 +74,22 @@ const contractSets = [ dir: solutionProductsContractsDir, files: ['admin-solution-products.v1.yaml'], }, + { + dir: aiServicesContractsDir, + files: ['admin-ai-services.v1.yaml'], + }, + { + dir: cloudServicesContractsDir, + files: ['admin-cloud-services.v1.yaml'], + }, + { + dir: dataAnalyticsContractsDir, + files: ['admin-data-analytics.v1.yaml'], + }, + { + dir: digitalTransformationContractsDir, + files: ['admin-digital-transformation.v1.yaml'], + }, ]; mkdirSync(outDir, { recursive: true }); diff --git a/apps/Administration-FE/src/components/AdminShell.tsx b/apps/Administration-FE/src/components/AdminShell.tsx index 3abcfbc..8675018 100644 --- a/apps/Administration-FE/src/components/AdminShell.tsx +++ b/apps/Administration-FE/src/components/AdminShell.tsx @@ -47,6 +47,14 @@ import SolutionDetailsList from './SolutionDetailsList'; import SolutionDetailForm from './SolutionDetailForm'; import SolutionProductsList from './SolutionProductsList'; import SolutionProductForm from './SolutionProductForm'; +import AiServicesList from './AiServicesList'; +import AiServiceForm from './AiServiceForm'; +import CloudServicesList from './CloudServicesList'; +import CloudServiceForm from './CloudServiceForm'; +import DataAnalyticsList from './DataAnalyticsList'; +import DataAnalyticsForm from './DataAnalyticsForm'; +import DigitalTransformationList from './DigitalTransformationList'; +import DigitalTransformationForm from './DigitalTransformationForm'; type View = AdminView; @@ -80,6 +88,10 @@ function applyRoute( setEditingSolutionId: (id: string | null) => void; setEditingSolutionDetailId: (id: string | null) => void; setEditingSolutionProductId: (id: string | null) => void; + setEditingAiServiceId: (id: string | null) => void; + setEditingCloudServiceId: (id: string | null) => void; + setEditingDataAnalyticId: (id: string | null) => void; + setEditingDigitalTransformationId: (id: string | null) => void; }, ) { setters.setView(route.view); @@ -103,6 +115,12 @@ function applyRoute( setters.setEditingSolutionProductId( route.view === 'solution_product_form' ? route.editingId : null, ); + setters.setEditingAiServiceId(route.view === 'ai_service_form' ? route.editingId : null); + setters.setEditingCloudServiceId(route.view === 'cloud_service_form' ? route.editingId : null); + setters.setEditingDataAnalyticId(route.view === 'data_analytics_form' ? route.editingId : null); + setters.setEditingDigitalTransformationId( + route.view === 'digital_transformation_form' ? route.editingId : null, + ); } export default function AdminShell() { @@ -169,6 +187,24 @@ export default function AdminShell() { const route = readAdminLocation(); return route.view === 'solution_product_form' ? route.editingId : null; }); + const [editingAiServiceId, setEditingAiServiceId] = useState(() => { + const route = readAdminLocation(); + return route.view === 'ai_service_form' ? route.editingId : null; + }); + const [editingCloudServiceId, setEditingCloudServiceId] = useState(() => { + const route = readAdminLocation(); + return route.view === 'cloud_service_form' ? route.editingId : null; + }); + const [editingDataAnalyticId, setEditingDataAnalyticId] = useState(() => { + const route = readAdminLocation(); + return route.view === 'data_analytics_form' ? route.editingId : null; + }); + const [editingDigitalTransformationId, setEditingDigitalTransformationId] = useState< + string | null + >(() => { + const route = readAdminLocation(); + return route.view === 'digital_transformation_form' ? route.editingId : null; + }); const [session, setSession] = useState(null); const [siteSettings, setSiteSettings] = useState | null>(null); const [message, setMessage] = useState(null); @@ -221,6 +257,10 @@ export default function AdminShell() { setEditingSolutionId, setEditingSolutionDetailId, setEditingSolutionProductId, + setEditingAiServiceId, + setEditingCloudServiceId, + setEditingDataAnalyticId, + setEditingDigitalTransformationId, }); }, []); @@ -302,6 +342,22 @@ export default function AdminShell() { document.title = t('admin.workspace.solution_products'); return; } + if (view === 'ai_services' || view === 'ai_service_form') { + document.title = t('admin.workspace.ai_services'); + return; + } + if (view === 'cloud_services' || view === 'cloud_service_form') { + document.title = t('admin.workspace.cloud_services'); + return; + } + if (view === 'data_analytics' || view === 'data_analytics_form') { + document.title = t('admin.workspace.data_analytics'); + return; + } + if (view === 'digital_transformation' || view === 'digital_transformation_form') { + document.title = t('admin.workspace.digital_transformation'); + return; + } if (view === 'blogs' || view === 'blog_form') { document.title = t('admin.workspace.blogs'); return; @@ -465,6 +521,22 @@ export default function AdminShell() { openList('solution_products'); } + function openAiServices() { + openList('ai_services'); + } + + function openCloudServices() { + openList('cloud_services'); + } + + function openDataAnalytics() { + openList('data_analytics'); + } + + function openDigitalTransformation() { + openList('digital_transformation'); + } + function onNavClick(event: MouseEvent, href: string) { if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) { return; @@ -498,6 +570,10 @@ export default function AdminShell() { const canPublishSolutions = hasPermission(session, 'solutions.publish'); const canPublishSolutionDetails = hasPermission(session, 'solution_details.publish'); const canPublishSolutionProducts = hasPermission(session, 'solution_products.publish'); + const canPublishAiServices = hasPermission(session, 'ai_services.publish'); + const canPublishCloudServices = hasPermission(session, 'cloud_services.publish'); + const canPublishDataAnalytics = hasPermission(session, 'data_analytics.publish'); + const canPublishDigitalTransformation = hasPermission(session, 'digital_transformation.publish'); const canManageRoles = canManageRolesFrom(session); const settingsCurrent = view === 'roles' || view === 'role_form'; const initial = session.email.slice(0, 1).toUpperCase(); @@ -506,6 +582,10 @@ export default function AdminShell() { const canReadSolutions = canReadResource(session, 'solutions'); const canReadSolutionDetails = canReadResource(session, 'solution_details'); const canReadSolutionProducts = canReadResource(session, 'solution_products'); + const canReadAiServices = canReadResource(session, 'ai_services'); + const canReadCloudServices = canReadResource(session, 'cloud_services'); + const canReadDataAnalytics = canReadResource(session, 'data_analytics'); + const canReadDigitalTransformation = canReadResource(session, 'digital_transformation'); const canReadBlogs = canReadResource(session, 'blogs'); const canReadCaseStudies = canReadResource(session, 'case_studies'); const canReadIndustries = canReadResource(session, 'industries'); @@ -523,6 +603,10 @@ export default function AdminShell() { canReadSolutions || canReadSolutionDetails || canReadSolutionProducts || + canReadAiServices || + canReadCloudServices || + canReadDataAnalytics || + canReadDigitalTransformation || canReadBlogs || canReadCaseStudies || canReadIndustries || @@ -637,6 +721,72 @@ export default function AdminShell() { )} + {canReadAiServices && ( +
  • + onNavClick(event, adminListHref('ai_services'))} + > + {t('admin.workspace.ai_services')} + +
  • + )} + {canReadCloudServices && ( +
  • + onNavClick(event, adminListHref('cloud_services'))} + > + {t('admin.workspace.cloud_services')} + +
  • + )} + {canReadDataAnalytics && ( +
  • + onNavClick(event, adminListHref('data_analytics'))} + > + {t('admin.workspace.data_analytics')} + +
  • + )} + {canReadDigitalTransformation && ( +
  • + onNavClick(event, adminListHref('digital_transformation'))} + > + {t('admin.workspace.digital_transformation')} + +
  • + )} {canReadBlogs && (
  • )} + {view === 'ai_services' && ( + openForm('ai_services', null)} + onEdit={(id) => openForm('ai_services', id)} + /> + )} + {view === 'ai_service_form' && ( + afterListSave(openAiServices, t('admin.ai_services.saved'))} + /> + )} + {view === 'cloud_services' && ( + openForm('cloud_services', null)} + onEdit={(id) => openForm('cloud_services', id)} + /> + )} + {view === 'cloud_service_form' && ( + afterListSave(openCloudServices, t('admin.cloud_services.saved'))} + /> + )} + {view === 'data_analytics' && ( + openForm('data_analytics', null)} + onEdit={(id) => openForm('data_analytics', id)} + /> + )} + {view === 'data_analytics_form' && ( + afterListSave(openDataAnalytics, t('admin.data_analytics.saved'))} + /> + )} + {view === 'digital_transformation' && ( + openForm('digital_transformation', null)} + onEdit={(id) => openForm('digital_transformation', id)} + /> + )} + {view === 'digital_transformation_form' && ( + + afterListSave(openDigitalTransformation, t('admin.digital_transformation.saved')) + } + /> + )} {view === 'blogs' && ( void; + onSaved: () => void; +} + +type IndustryDraft = { + key: string; + title: string; + image_key: string | null; + imageFile: File | null; + order: number; +}; + +type AccordionDraft = { key: string; title: string; contents: string; order: number }; + +function nextKey(): string { + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function emptyIndustry(): IndustryDraft { + return { key: nextKey(), title: '', image_key: null, imageFile: null, order: 0 }; +} + +function emptyAccordion(): AccordionDraft { + return { key: nextKey(), title: '', contents: '', order: 0 }; +} + +export default function AiServiceForm({ entryId, canPublish, onCancel, onSaved }: Props) { + const [bannerTitle, setBannerTitle] = useState(''); + const [bannerKey, setBannerKey] = useState(null); + const [bannerFile, setBannerFile] = useState(null); + const [introductionTitle, setIntroductionTitle] = useState(''); + const [introductionDescription, setIntroductionDescription] = useState(''); + const [solutionsTitle, setSolutionsTitle] = useState(''); + const [solutionsDescription, setSolutionsDescription] = useState(''); + const [industryTitle, setIndustryTitle] = useState(''); + const [industryDescription, setIndustryDescription] = useState(''); + const [industryItems, setIndustryItems] = useState([emptyIndustry()]); + const [expertiseTitle, setExpertiseTitle] = useState(''); + const [expertiseKey, setExpertiseKey] = useState(null); + const [expertiseFile, setExpertiseFile] = useState(null); + const [expertiseAccordion, setExpertiseAccordion] = useState([emptyAccordion()]); + const [expertiseAccordionDescription, setExpertiseAccordionDescription] = useState(''); + const [solutionIds, setSolutionIds] = useState([]); + const [solutionOptions, setSolutionOptions] = useState<{ id: string; name: string }[]>([]); + const [faqTitle, setFaqTitle] = useState(''); + const [faqDescription, setFaqDescription] = useState(''); + const [faqAccordion, setFaqAccordion] = useState([emptyAccordion()]); + const [seo, setSeo] = useState(emptySeo); + const [seoImageFile, setSeoImageFile] = useState(null); + const [status, setStatus] = useState<'draft' | 'publish'>('draft'); + const [error, setError] = useState(null); + const [ready, setReady] = useState(!entryId); + const [saving, setSaving] = useState(false); + + useEffect(() => { + async function loadSolutionDetails() { + const options: { id: string; name: string }[] = []; + let page = 1; + while (true) { + const result = await listSolutionDetails('', page); + options.push( + ...result.items.map((item) => ({ id: item.id, name: item.title || item.id })), + ); + if (page * result.per_page >= result.total) break; + page += 1; + } + setSolutionOptions(options); + } + loadSolutionDetails().catch(() => undefined); + }, []); + + useEffect(() => { + if (!entryId) return; + getAiService(entryId) + .then(async (item) => { + setBannerTitle(item.banner_title); + setBannerKey(item.banner_image_key); + setIntroductionTitle(item.introduction_title); + setIntroductionDescription(item.introduction_description); + setSolutionsTitle(item.solutions_title); + setSolutionsDescription(item.solutions_description); + setIndustryTitle(item.industry_title); + setIndustryDescription(item.industry_description); + setIndustryItems( + item.industry_items.length + ? item.industry_items.map((row) => ({ + key: nextKey(), + title: row.title, + image_key: row.image_key ?? null, + imageFile: null, + order: row.order, + })) + : [emptyIndustry()], + ); + setExpertiseTitle(item.ai_expertise_title); + setExpertiseKey(item.ai_expertise_image_key); + setExpertiseAccordion( + item.ai_expertise_accordion.length + ? await Promise.all( + item.ai_expertise_accordion.map(async (row) => ({ + key: nextKey(), + title: row.title, + contents: await hydrateRichText(row.contents), + order: row.order, + })), + ) + : [emptyAccordion()], + ); + setExpertiseAccordionDescription( + await hydrateRichText(item.ai_expertise_accordion_description), + ); + setSolutionIds(item.solution_ids); + setFaqTitle(item.faq_title); + setFaqDescription(await hydrateRichText(item.faq_description)); + setFaqAccordion( + item.faq_accordion.length + ? await Promise.all( + item.faq_accordion.map(async (row) => ({ + key: nextKey(), + title: row.title, + contents: await hydrateRichText(row.contents), + order: row.order, + })), + ) + : [emptyAccordion()], + ); + setSeo(seoValue(item.seo)); + setStatus(item.status); + setReady(true); + }) + .catch(() => { + setError(t('admin.workspace.request_failed')); + setReady(true); + }); + }, [entryId]); + + async function persist(nextStatus: 'draft' | 'publish') { + setError(null); + setSaving(true); + try { + const nextBanner = bannerFile ? (await uploadMedia(bannerFile)).key : bannerKey; + const nextExpertise = expertiseFile ? (await uploadMedia(expertiseFile)).key : expertiseKey; + const nextSeoImage = seoImageFile ? (await uploadMedia(seoImageFile)).key : seo.image_key; + const industries = []; + for (const [index, item] of industryItems.entries()) { + const image_key = item.imageFile ? (await uploadMedia(item.imageFile)).key : item.image_key; + industries.push({ title: item.title.trim(), image_key, order: index }); + } + const payload: AiServiceWrite = { + banner_title: bannerTitle.trim(), + banner_image_key: nextBanner, + introduction_title: introductionTitle.trim(), + introduction_description: introductionDescription.trim(), + solutions_title: solutionsTitle.trim(), + solutions_description: solutionsDescription.trim(), + industry_title: industryTitle.trim(), + industry_description: industryDescription.trim(), + industry_items: industries, + ai_expertise_title: expertiseTitle.trim(), + ai_expertise_image_key: nextExpertise, + ai_expertise_accordion: expertiseAccordion.map((item, index) => ({ + title: item.title.trim(), + contents: persistRichText(item.contents), + order: index, + })), + ai_expertise_accordion_description: persistRichText(expertiseAccordionDescription), + solution_ids: solutionIds, + faq_title: faqTitle.trim(), + faq_description: persistRichText(faqDescription), + faq_accordion: faqAccordion.map((item, index) => ({ + title: item.title.trim(), + contents: persistRichText(item.contents), + order: index, + })), + seo: { ...seo, image_key: nextSeoImage }, + status: nextStatus, + }; + if (entryId) await updateAiService(entryId, payload); + else await createAiService(payload); + onSaved(); + } catch (caught) { + setError(apiErrorMessage(caught)); + } finally { + setSaving(false); + } + } + + if (!ready) { + return ( +
    +

    +

    +
    + ); + } + + return ( +
    +
    +

    {entryId ? t('admin.ai_services.edit') : t('admin.ai_services.add')}

    +
    +
    { + event.preventDefault(); + persist(status).catch(() => undefined); + }} + > + + + + +