Group dashboard activities under primary administration and tidy editorial overview
tests / ci (push) Waiting to run
tests / ci (push) Waiting to run
This commit is contained in:
@@ -57,6 +57,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
return [
|
return [
|
||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
|
'dashboardNavigation' => app(ContentManagement::class)->dashboardNavigation($request->user(), $contentManagementAccess),
|
||||||
'procurementEnabled' => app(Procurement::class)->available(),
|
'procurementEnabled' => app(Procurement::class)->available(),
|
||||||
'fundingEnabled' => app(FundingProgrammes::class)->installed(),
|
'fundingEnabled' => app(FundingProgrammes::class)->installed(),
|
||||||
'homologationReserved' => (bool) config('portal.homologation_only', false),
|
'homologationReserved' => (bool) config('portal.homologation_only', false),
|
||||||
|
|||||||
@@ -51,6 +51,43 @@ final class ContentManagement
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param array<array-key,mixed> $access
|
||||||
|
* @return list<array{label:string,href:string}>
|
||||||
|
*/
|
||||||
|
public function dashboardNavigation(?User $actor, array $access): array
|
||||||
|
{
|
||||||
|
$workflow = app(PortalEditorialWorkflow::class);
|
||||||
|
if (! $actor || ! $workflow->available() || ! $workflow->canEnter($actor)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$items = [['label' => 'Visão geral', 'href' => '/management/editorial']];
|
||||||
|
$documents = app(DocumentManagementAccess::class);
|
||||||
|
if ($documents->full($actor) || $documents->editorial($actor)) {
|
||||||
|
foreach (['article' => 'Artigos', 'legislation' => 'Legislação', 'document' => 'Documentos'] as $kind => $label) {
|
||||||
|
$items[] = ['label' => $label, 'href' => '/management/editorial?kind='.$kind];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['opportunities' => 'Oportunidades', 'clusters' => 'Competitividade', 'portfolio' => 'Carteira', 'demography' => 'Demografia'] as $key => $label) {
|
||||||
|
if (isset($access[$key]['index'])) {
|
||||||
|
$items[] = ['label' => $label, 'href' => $access[$key]['index']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (app(PortalEditorialSubjects::class)->available() && $actor->can('manage-portal')) {
|
||||||
|
$items[] = ['label' => 'Estruturas e relações', 'href' => '/management/editorial/subjects'];
|
||||||
|
}
|
||||||
|
$native = app(PortalEditorialNativeNotices::class);
|
||||||
|
if ($native->access($actor, 'territorial-editorial-v1')) {
|
||||||
|
$items[] = ['label' => 'Conteúdos territoriais', 'href' => '/management/territorial/editorial'];
|
||||||
|
}
|
||||||
|
if ($native->access($actor, 'investment-zones')) {
|
||||||
|
$items[] = ['label' => 'Zonas e indicadores', 'href' => '/management/investment/zones'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$items[] = ['label' => 'Notificações', 'href' => '/management/editorial#notificacoes'];
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<array-key, mixed>
|
* @return array<array-key, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
## 14/11/2026 — Administração principal e apresentação agrupada do dashboard em QA
|
||||||
|
|
||||||
|
Pedido expresso da Tempo: Administração fica no menu principal e Dashboard reúne as actividades num dropdown interno. Esta alteração posterior substitui, apenas para contas com destinos administrativos autorizados, a anterior opção de manter Administração dentro de Mais. Visitantes e contas sem esses destinos conservam os seis acessos públicos; não se apresenta um menu administrativo vazio.
|
||||||
|
|
||||||
|
O catálogo de navegação comum é reutilizado no cabeçalho e na navegação lateral. Administração contém Dashboard → Visão geral, Artigos, Legislação, Documentos, Oportunidades, Competitividade, Carteira, Demografia, Estruturas e relações, Conteúdos territoriais, Zonas e indicadores e Notificações, apenas conforme as autorizações e componentes existentes. O painel operacional anterior também passa para Dashboard quando a conta já tem esse destino autorizado. As restantes operações administrativas mantêm os seus destinos, sem duplicar ligações. O grupo activo acompanha a página actual. Em ecrãs pequenos os subgrupos expandem por toque/teclado, sem exigir abertura por hover.
|
||||||
|
|
||||||
|
A página `/management/editorial` passa a apresentar três grupos consistentes: **Conteúdos e oportunidades**, **Investimentos e competitividade**, **Território e dados**. Os acessos, contadores e estados existentes ficam alinhados em cartões, com adaptação de uma a três colunas, fila de conteúdos e notificações identificadas. Retiraram-se as ligações dispersas e o segundo bloco repetido de oportunidades. Guardas, filtros, decisões e histórico não mudam.
|
||||||
|
|
||||||
|
Provas em `outputs/dashboard-navigation-20261114`: 26 testes de navegação/apresentação, integração de avisos/acesso 4/32, análise estática, tipos e compilação aprovados. Testam-se ausência de administração para visitantes/contas sem âmbito, preservação de destinos, deduplicação, localização do grupo activo, dropdown desktop, expansão móvel, grupos/counters e omissão de áreas indisponíveis. Sem migrações, dados ou permissões alteradas. A conferência visual permanece pendente pelo bloqueio do conector já documentado; os testes de renderização não são apresentados como conferência visual.
|
||||||
|
|
||||||
|
**Estado:** implementado/verificado em QA, ainda sem instalação desta nova apresentação. A homologação mantém `a4541298`. Preparar uma única entrega pelo processo Git existente, com backup/restauro e autorização concreta; não reinstalar o complemento anterior nem republicar conteúdos.
|
||||||
|
|
||||||
## 14/11/2026 — complemento do dashboard instalado e activo em a4541298
|
## 14/11/2026 — complemento do dashboard instalado e activo em a4541298
|
||||||
|
|
||||||
**Instalação conjunta concluída**, após autorização expressa da Tempo. A revisão `a4541298c0a386ab8bfd8e3e531e12d432ec671b` está confirmada em `https://teste-investidor.tempo.co.mz/build/revision.json`. Utilização: `/management/editorial`, com login/MFA e os âmbitos existentes. Estruturas/relações/cadeias, revisão das zonas e indicadores documentais e notificações nativas estão activos. A designação jurídica nominal recebida foi aplicada à configuração protegida; a conta existe e tem correio verificado. Não se alteraram permissões gerais nem se publicaram conteúdos automaticamente.
|
**Instalação conjunta concluída**, após autorização expressa da Tempo. A revisão `a4541298c0a386ab8bfd8e3e531e12d432ec671b` está confirmada em `https://teste-investidor.tempo.co.mz/build/revision.json`. Utilização: `/management/editorial`, com login/MFA e os âmbitos existentes. Estruturas/relações/cadeias, revisão das zonas e indicadores documentais e notificações nativas estão activos. A designação jurídica nominal recebida foi aplicada à configuração protegida; a conta existe e tem correio verificado. Não se alteraram permissões gerais nem se publicaram conteúdos automaticamente.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -32,6 +32,7 @@ export function AppSidebar() {
|
|||||||
contentManagementAccess,
|
contentManagementAccess,
|
||||||
profileAccess,
|
profileAccess,
|
||||||
scopedAdministration,
|
scopedAdministration,
|
||||||
|
dashboardNavigation,
|
||||||
} = usePage<{
|
} = usePage<{
|
||||||
auth: Auth;
|
auth: Auth;
|
||||||
existingBusinessEnabled?: boolean;
|
existingBusinessEnabled?: boolean;
|
||||||
@@ -41,6 +42,7 @@ export function AppSidebar() {
|
|||||||
opportunityManagement?: OpportunityManagementAccess | null;
|
opportunityManagement?: OpportunityManagementAccess | null;
|
||||||
contentManagementHref?: string | null;
|
contentManagementHref?: string | null;
|
||||||
scopedAdministration?: { label: string; href: string }[];
|
scopedAdministration?: { label: string; href: string }[];
|
||||||
|
dashboardNavigation?: { label: string; href: string }[];
|
||||||
contentManagementAccess?: {
|
contentManagementAccess?: {
|
||||||
opportunities?: { index: string; create?: string | null };
|
opportunities?: { index: string; create?: string | null };
|
||||||
};
|
};
|
||||||
@@ -56,6 +58,7 @@ export function AppSidebar() {
|
|||||||
contentManagementHref,
|
contentManagementHref,
|
||||||
contentManagementAccess,
|
contentManagementAccess,
|
||||||
scopedAdministration,
|
scopedAdministration,
|
||||||
|
dashboardNavigation,
|
||||||
legacyContent: currentPage.props.legacyContentEnabled as
|
legacyContent: currentPage.props.legacyContentEnabled as
|
||||||
| boolean
|
| boolean
|
||||||
| undefined,
|
| undefined,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function PortalGroupedMenu() {
|
|||||||
profileAccess?: ProfileAccess | null;
|
profileAccess?: ProfileAccess | null;
|
||||||
opportunityManagement?: OpportunityManagementAccess | null;
|
opportunityManagement?: OpportunityManagementAccess | null;
|
||||||
scopedAdministration?: { label: string; href: string }[];
|
scopedAdministration?: { label: string; href: string }[];
|
||||||
|
dashboardNavigation?: { label: string; href: string }[];
|
||||||
contentManagementHref?: string | null;
|
contentManagementHref?: string | null;
|
||||||
contentManagementAccess?: {
|
contentManagementAccess?: {
|
||||||
opportunities?: { index: string; create?: string | null };
|
opportunities?: { index: string; create?: string | null };
|
||||||
@@ -79,6 +80,7 @@ export function PortalGroupedMenu() {
|
|||||||
contentManagementHref: props.contentManagementHref,
|
contentManagementHref: props.contentManagementHref,
|
||||||
contentManagementAccess: props.contentManagementAccess,
|
contentManagementAccess: props.contentManagementAccess,
|
||||||
scopedAdministration: props.scopedAdministration,
|
scopedAdministration: props.scopedAdministration,
|
||||||
|
dashboardNavigation: props.dashboardNavigation,
|
||||||
});
|
});
|
||||||
const current = currentNavigationGroup(groups, url);
|
const current = currentNavigationGroup(groups, url);
|
||||||
const currentItem = currentNavigationItem(groups, url);
|
const currentItem = currentNavigationItem(groups, url);
|
||||||
@@ -93,12 +95,17 @@ export function PortalGroupedMenu() {
|
|||||||
children.map((item) =>
|
children.map((item) =>
|
||||||
item.children ? (
|
item.children ? (
|
||||||
mobile ? (
|
mobile ? (
|
||||||
<Fragment key={item.id}>
|
<details
|
||||||
<DropdownMenuLabel className="mt-2 border-t px-3 pt-3 text-xs text-slate-500">
|
key={item.id}
|
||||||
|
className="rounded-lg border-b border-slate-100"
|
||||||
|
>
|
||||||
|
<summary className="cursor-pointer px-3 py-3 text-sm font-semibold text-slate-800">
|
||||||
{t(item.label)}
|
{t(item.label)}
|
||||||
</DropdownMenuLabel>
|
</summary>
|
||||||
{items(item.children)}
|
<div className="border-l border-slate-200 pl-2">
|
||||||
</Fragment>
|
{items(item.children)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuSub key={item.id}>
|
<DropdownMenuSub key={item.id}>
|
||||||
<DropdownMenuSubTrigger className="tempo-menu-item font-semibold">
|
<DropdownMenuSubTrigger className="tempo-menu-item font-semibold">
|
||||||
@@ -138,6 +145,7 @@ export function PortalGroupedMenu() {
|
|||||||
'realization',
|
'realization',
|
||||||
'support',
|
'support',
|
||||||
'work',
|
'work',
|
||||||
|
'administration',
|
||||||
].includes(group.id),
|
].includes(group.id),
|
||||||
);
|
);
|
||||||
const institutionalCurrent = url.split('?')[0] === '/contacto';
|
const institutionalCurrent = url.split('?')[0] === '/contacto';
|
||||||
@@ -203,6 +211,7 @@ export function PortalGroupedMenu() {
|
|||||||
'discovery',
|
'discovery',
|
||||||
'realization',
|
'realization',
|
||||||
'support',
|
'support',
|
||||||
|
'administration',
|
||||||
].includes(group.id),
|
].includes(group.id),
|
||||||
)
|
)
|
||||||
.map((group) => (
|
.map((group) => (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type PortalNavAccess = {
|
|||||||
opportunities?: { index: string; create?: string | null };
|
opportunities?: { index: string; create?: string | null };
|
||||||
};
|
};
|
||||||
scopedAdministration?: { label: string; href: string }[];
|
scopedAdministration?: { label: string; href: string }[];
|
||||||
|
dashboardNavigation?: { label: string; href: string }[];
|
||||||
procurement?: boolean;
|
procurement?: boolean;
|
||||||
funding?: boolean;
|
funding?: boolean;
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
@@ -477,6 +478,44 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
|
|||||||
? access.opportunityManagement.create.href
|
? access.opportunityManagement.create.href
|
||||||
: null)
|
: null)
|
||||||
: null;
|
: null;
|
||||||
|
const dashboardActivities = access.authenticated
|
||||||
|
? [
|
||||||
|
...(access.dashboardNavigation ?? []),
|
||||||
|
...previous
|
||||||
|
.filter((group) => group.id === 'account')
|
||||||
|
.flatMap((group) => group.children ?? [])
|
||||||
|
.filter((item) => item.href === '/dashboard')
|
||||||
|
.map((item) => ({
|
||||||
|
label: 'Painel operacional',
|
||||||
|
href: item.href!,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
const administration = access.authenticated
|
||||||
|
? [
|
||||||
|
...(dashboardActivities.length
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'collaborative-dashboard',
|
||||||
|
label: 'Dashboard',
|
||||||
|
children: dashboardActivities.map((item, index) =>
|
||||||
|
link(
|
||||||
|
'dashboard-activity-' + index,
|
||||||
|
item.label,
|
||||||
|
item.href,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...previous
|
||||||
|
.filter((group) => group.id === 'management')
|
||||||
|
.flatMap((group) => group.children ?? []),
|
||||||
|
...(access.scopedAdministration ?? []).map((item, index) =>
|
||||||
|
link('scoped-' + index, item.label, item.href),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
return uniqueNavigationDestinations([
|
return uniqueNavigationDestinations([
|
||||||
{
|
{
|
||||||
id: 'discovery',
|
id: 'discovery',
|
||||||
@@ -603,6 +642,15 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
...(administration.length
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'administration',
|
||||||
|
label: 'Administração',
|
||||||
|
children: administration,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: 'work',
|
id: 'work',
|
||||||
label: 'Área de trabalho',
|
label: 'Área de trabalho',
|
||||||
@@ -633,28 +681,8 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
|
|||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...previous
|
...previous
|
||||||
.filter((group) =>
|
.filter((group) => group.id === 'profile-opportunities')
|
||||||
['management', 'profile-opportunities'].includes(
|
|
||||||
group.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.flatMap((group) => group.children ?? []),
|
.flatMap((group) => group.children ?? []),
|
||||||
...(access.authenticated && access.scopedAdministration?.length
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: 'work-administration',
|
|
||||||
label: 'Administração',
|
|
||||||
children: access.scopedAdministration.map(
|
|
||||||
(item, index) =>
|
|
||||||
link(
|
|
||||||
'scoped-' + index,
|
|
||||||
item.label,
|
|
||||||
item.href,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
...previous.filter((group) => group.id === 'account'),
|
...previous.filter((group) => group.id === 'account'),
|
||||||
|
|||||||
@@ -85,81 +85,126 @@ export default function EditorialWorkflow({
|
|||||||
}) {
|
}) {
|
||||||
const pageUrl = (page: number) =>
|
const pageUrl = (page: number) =>
|
||||||
`/management/editorial?${new URLSearchParams({ ...filters, page: String(page) })}`;
|
`/management/editorial?${new URLSearchParams({ ...filters, page: String(page) })}`;
|
||||||
|
const areas = [
|
||||||
|
{
|
||||||
|
title: 'Conteúdos e oportunidades',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
label: 'Artigos',
|
||||||
|
href: resourceAccess
|
||||||
|
? '/management/editorial?kind=article'
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Legislação',
|
||||||
|
href: resourceAccess
|
||||||
|
? '/management/editorial?kind=legislation'
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Documentos',
|
||||||
|
href: resourceAccess
|
||||||
|
? '/management/editorial?kind=document'
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Oportunidades',
|
||||||
|
href: opportunitiesUrl,
|
||||||
|
pending: opportunityPending,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Biblioteca e editores',
|
||||||
|
href: resourceAccess ? '/management/portal-services' : null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Investimentos e competitividade',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
label: 'Carteira',
|
||||||
|
href: portfolioUrl,
|
||||||
|
pending: portfolioPending,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Competitividade dos clusters',
|
||||||
|
href: clustersUrl,
|
||||||
|
pending: nativePending.clusters,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Estruturas e relações',
|
||||||
|
href: subjectsUrl,
|
||||||
|
pending: subjectsPending,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Território e dados',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
label: 'Conteúdos territoriais',
|
||||||
|
href: territorialUrl,
|
||||||
|
pending: nativePending.territories,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Zonas e indicadores',
|
||||||
|
href: zonesUrl,
|
||||||
|
pending: nativePending.zones ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Demografia',
|
||||||
|
href: demographyUrl,
|
||||||
|
pending: demographyPending,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
].filter((area) => area.entries.some((item) => item.href));
|
||||||
return (
|
return (
|
||||||
<Management title="Circuito editorial">
|
<Management title="Dashboard">
|
||||||
<p>
|
<p className="max-w-3xl text-sm text-slate-600">
|
||||||
Artigos, legislação e documentos: edições privadas e versão
|
Acompanhe a preparação, revisão e publicação numa única área de
|
||||||
pública separadas. As oportunidades mantêm o seu circuito
|
trabalho. Seleccione uma actividade para continuar nas tarefas
|
||||||
próprio.
|
da sua equipa.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-4">
|
<section
|
||||||
{zonesUrl && (
|
aria-label="Actividades do dashboard"
|
||||||
<Link className="underline" href={zonesUrl}>
|
className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||||
Zonas e indicadores: {nativePending.zones ?? 0}{' '}
|
>
|
||||||
pendentes
|
{areas.map((area) => (
|
||||||
</Link>
|
<section
|
||||||
)}
|
key={area.title}
|
||||||
{subjectsUrl && (
|
className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"
|
||||||
<Link className="underline" href={subjectsUrl}>
|
|
||||||
Estruturas e relações: {subjectsPending} pendentes
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{demographyUrl && (
|
|
||||||
<Link className="underline" href={demographyUrl}>
|
|
||||||
Demografia: {demographyPending} lotes pendentes
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{territorialUrl && (
|
|
||||||
<Link className="underline" href={territorialUrl}>
|
|
||||||
Conteúdos territoriais: {nativePending.territories}{' '}
|
|
||||||
pendentes
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{portfolioUrl && (
|
|
||||||
<Link className="underline" href={portfolioUrl}>
|
|
||||||
Carteira: {portfolioPending} fichas pendentes
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{clustersUrl && (
|
|
||||||
<Link className="underline" href={clustersUrl}>
|
|
||||||
Competitividade dos clusters: {nativePending.clusters}{' '}
|
|
||||||
pendentes
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<Link className="underline" href="/dashboard">
|
|
||||||
Painel geral
|
|
||||||
</Link>
|
|
||||||
{resourceAccess && (
|
|
||||||
<Link
|
|
||||||
className="underline"
|
|
||||||
href="/management/portal-services"
|
|
||||||
>
|
>
|
||||||
Biblioteca e editores
|
<h2 className="border-b border-slate-100 bg-slate-50 px-5 py-4 text-sm font-semibold text-slate-900">
|
||||||
</Link>
|
{area.title}
|
||||||
)}
|
</h2>
|
||||||
{opportunitiesUrl && (
|
<ul className="divide-y divide-slate-100">
|
||||||
<Link className="underline" href={opportunitiesUrl}>
|
{area.entries.map((item) =>
|
||||||
Oportunidades
|
item.href ? (
|
||||||
</Link>
|
<li key={item.label}>
|
||||||
)}
|
<Link
|
||||||
</div>
|
href={item.href}
|
||||||
{opportunitiesUrl && (
|
className="flex min-h-14 items-center justify-between gap-3 px-5 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-red-700"
|
||||||
<section
|
>
|
||||||
aria-label="Circuito de oportunidades"
|
<span>{item.label}</span>
|
||||||
className="my-4 rounded border p-4"
|
{'pending' in item && (
|
||||||
>
|
<span className="shrink-0 rounded-full bg-slate-100 px-2.5 py-1 text-xs text-slate-600">
|
||||||
<h2 className="font-semibold">Oportunidades</h2>
|
{item.pending} pendentes
|
||||||
<p>
|
</span>
|
||||||
{opportunityPending} conteúdos pendentes no seu âmbito
|
)}
|
||||||
de acesso.
|
</Link>
|
||||||
</p>
|
</li>
|
||||||
<Link className="underline" href={opportunitiesUrl}>
|
) : null,
|
||||||
Continuar na fila de oportunidades existente
|
)}
|
||||||
</Link>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
)}
|
))}
|
||||||
|
</section>
|
||||||
{resourceAccess && (
|
{resourceAccess && (
|
||||||
<>
|
<>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
Fila de conteúdos
|
||||||
|
</h2>
|
||||||
<form
|
<form
|
||||||
action="/management/editorial"
|
action="/management/editorial"
|
||||||
className="my-4 flex flex-wrap items-end gap-3"
|
className="my-4 flex flex-wrap items-end gap-3"
|
||||||
@@ -239,10 +284,22 @@ export default function EditorialWorkflow({
|
|||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<section aria-label="Notificações editoriais">
|
<section
|
||||||
<h2 className="font-semibold">Notificações</h2>
|
id="notificacoes"
|
||||||
|
aria-label="Notificações editoriais"
|
||||||
|
className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm"
|
||||||
|
>
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">Notificações</h2>
|
||||||
|
{notices.length === 0 && (
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Não há notificações neste âmbito.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{notices.map((n) => (
|
{notices.map((n) => (
|
||||||
<p key={n.id}>
|
<p
|
||||||
|
key={n.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 py-3 text-sm"
|
||||||
|
>
|
||||||
<Link className="underline" href={n.href}>
|
<Link className="underline" href={n.href}>
|
||||||
{eventTitles[n.action] ?? 'Alteração editorial'} ·{' '}
|
{eventTitles[n.action] ?? 'Alteração editorial'} ·{' '}
|
||||||
{n.reference}
|
{n.reference}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Tests\Feature;
|
|||||||
use App\Models\EconomicCluster;
|
use App\Models\EconomicCluster;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ClusterCompetitiveness;
|
use App\Services\ClusterCompetitiveness;
|
||||||
|
use App\Services\ContentManagement;
|
||||||
use App\Services\PortalEditorialNativeNotices;
|
use App\Services\PortalEditorialNativeNotices;
|
||||||
use App\Services\PortalEditorialWorkflow;
|
use App\Services\PortalEditorialWorkflow;
|
||||||
use App\Services\TerritorialRegistry;
|
use App\Services\TerritorialRegistry;
|
||||||
@@ -34,6 +35,19 @@ final class EditorialNativeNoticeTest extends TestCase
|
|||||||
$this->actingAsWithMfa($this->author);
|
$this->actingAsWithMfa($this->author);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_dashboard_navigation_uses_existing_scopes_and_does_not_expose_disabled_or_private_activities(): void
|
||||||
|
{
|
||||||
|
$service = app(ContentManagement::class);
|
||||||
|
$items = $service->dashboardNavigation($this->author, $service->access($this->author));
|
||||||
|
$this->assertSame(['label' => 'Visão geral', 'href' => '/management/editorial'], $items[0]);
|
||||||
|
$this->assertContains('/management/clusters', array_column($items, 'href'));
|
||||||
|
$outsider = User::factory()->create();
|
||||||
|
$this->assertSame([], $service->dashboardNavigation($outsider, []));
|
||||||
|
$this->assertSame([], $service->dashboardNavigation(null, []));
|
||||||
|
config(['editorial_workflow.enabled' => false]);
|
||||||
|
$this->assertSame([], $service->dashboardNavigation($this->author, $service->access($this->author)));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_cluster_events_use_the_common_inbox_and_recheck_current_scope_and_event_integrity(): void
|
public function test_cluster_events_use_the_common_inbox_and_recheck_current_scope_and_event_integrity(): void
|
||||||
{
|
{
|
||||||
$cluster = EconomicCluster::create(['code' => 'NOTICE-'.Str::random(12), 'name' => 'Synthetic notice cluster', 'sector' => 'Energy', 'status' => 'planned']);
|
$cluster = EconomicCluster::create(['code' => 'NOTICE-'.Str::random(12), 'name' => 'Synthetic notice cluster', 'sector' => 'Energy', 'status' => 'planned']);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import {createRequire} from 'node:module';
|
||||||
|
import React from 'react';
|
||||||
|
import {renderToStaticMarkup} from 'react-dom/server';
|
||||||
|
import ts from 'typescript';
|
||||||
|
const require=createRequire(import.meta.url), module={exports:{}};
|
||||||
|
const code=ts.transpileModule(fs.readFileSync(new URL('../../resources/js/pages/management/editorial-workflow.tsx',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX}}).outputText;
|
||||||
|
new Function('require','module','exports',code)(name=>name==='@inertiajs/react'?{Link:({href,children,...props})=>React.createElement('a',{href,...props},children),router:{}}:name==='./shared'?{Management:({title,children})=>React.createElement('main',null,React.createElement('h1',null,title),children),Field:({label,children})=>React.createElement('label',null,label,children),button:'',control:''}:require(name),module,module.exports);
|
||||||
|
const base={items:{data:[],current_page:1,last_page:1},notices:[],resourceAccess:true,opportunitiesUrl:'/opportunities-qa',opportunityPending:2,clustersUrl:'/clusters-qa',nativePending:{clusters:3,territories:0,zones:0},subjectsUrl:'/subjects-qa',territorialUrl:'/territories-qa',demographyUrl:'/demography-qa'};
|
||||||
|
test('dashboard groups authorised activities once with contextual counts and an empty notification state',()=>{
|
||||||
|
const html=renderToStaticMarkup(React.createElement(module.exports.default,base));
|
||||||
|
for(const title of ['Conteúdos e oportunidades','Investimentos e competitividade','Território e dados'])assert.match(html,new RegExp(title));
|
||||||
|
assert.equal((html.match(/href="\/opportunities-qa"/g)??[]).length,1);
|
||||||
|
assert.match(html,/2 pendentes/);assert.match(html,/3 pendentes/);
|
||||||
|
assert.match(html,/Não há notificações neste âmbito/);
|
||||||
|
assert.match(html,/md:grid-cols-2 xl:grid-cols-3/);
|
||||||
|
});
|
||||||
|
test('restricted dashboard omits unavailable groups, private content controls and counters',()=>{
|
||||||
|
const html=renderToStaticMarkup(React.createElement(module.exports.default,{items:base.items,notices:[],resourceAccess:false,opportunitiesUrl:null,opportunityPending:0}));
|
||||||
|
assert.doesNotMatch(html,/Fila de conteúdos|kind=legislation|Biblioteca e editores|Investimentos e competitividade|Território e dados/);
|
||||||
|
});
|
||||||
@@ -238,7 +238,7 @@ void test('work area exposes existing content capability even when the old dashb
|
|||||||
);
|
);
|
||||||
assert.ok(work.children.some((i) => i.href === '/gestao-conteudos'));
|
assert.ok(work.children.some((i) => i.href === '/gestao-conteudos'));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
work.children.some((i) => i.href === '/management/opportunities'),
|
destinations(groups.find(g => g.id === 'administration').children).includes('/management/opportunities'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
void test('every previously authorised management/profile destination survives the regrouping exactly once', () => {
|
void test('every previously authorised management/profile destination survives the regrouping exactly once', () => {
|
||||||
@@ -276,7 +276,7 @@ void test('every previously authorised management/profile destination survives t
|
|||||||
assert.equal(after.filter((x) => x === href).length, 1, href);
|
assert.equal(after.filter((x) => x === href).length, 1, href);
|
||||||
assert.equal(after.length, new Set(after).size);
|
assert.equal(after.length, new Set(after).size);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
destinations(groups.find((g) => g.id === 'work').children).includes(
|
destinations(groups.find((g) => g.id === 'administration').children).includes(
|
||||||
'/management/economic-references/lot-01',
|
'/management/economic-references/lot-01',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -448,3 +448,17 @@ void test('installed concours has its own primary group and preserves public and
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('administration owns dashboard activities once and remains hidden without authorised destinations', () => {
|
||||||
|
const dashboardNavigation = [{label: 'Visão geral', href: '/management/editorial'}, {label: 'Legislação', href: '/management/editorial?kind=legislation'}, {label: 'Competitividade', href: '/management/clusters'}];
|
||||||
|
const groups = portalNavigation({authenticated: true, manager: true, dashboardNavigation});
|
||||||
|
const admin = groups.find(g => g.id === 'administration');
|
||||||
|
const dashboard = admin.children.find(g => g.id === 'collaborative-dashboard');
|
||||||
|
assert.equal(dashboard.label, 'Dashboard');
|
||||||
|
assert.deepEqual(destinations(dashboard.children), dashboardNavigation.map(i => i.href));
|
||||||
|
assert.equal(currentNavigationGroup(groups, '/management/editorial?kind=legislation'), 'administration');
|
||||||
|
assert.equal(currentNavigationGroup(groups, '/management/clusters/synthetic/edit'), 'administration');
|
||||||
|
assert.equal(destinations(groups).filter(h => h === '/management/clusters').length, 1);
|
||||||
|
assert.equal(portalNavigation({authenticated: false, manager: false, dashboardNavigation}).some(g => g.id === 'administration'), false);
|
||||||
|
assert.equal(portalNavigation({authenticated: true, manager: false}).some(g => g.id === 'administration'), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -192,3 +192,17 @@ test('actual header has exactly the requested six primary entries on desktop and
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('authorised administration is primary and dashboard is a nested expandable group on desktop and mobile', () => {
|
||||||
|
for (mobile of [false, true]) {
|
||||||
|
state = {url: '/management/editorial', props: {auth: {user: {name: 'QA'}, canManagePortal: false}, dashboardNavigation: [{label: 'Visão geral', href: '/management/editorial'}, {label: 'Legislação', href: '/management/editorial?kind=legislation'}]}};
|
||||||
|
const html = renderToStaticMarkup(React.createElement(module.exports.PortalGroupedMenu));
|
||||||
|
const nav = html.match(/<nav[^>]*aria-label="Áreas do portal"[\s\S]*?<\/nav>/)?.[0];
|
||||||
|
assert.ok(nav);
|
||||||
|
const labels = [...nav.matchAll(/<(?:a|button)[^>]*class="tempo-nav-trigger"[^>]*>([\s\S]*?)<\/(?:a|button)>/g)].map(m => m[1].replace(/<[^>]*>/g, '').replace('Grupo actual', '').trim());
|
||||||
|
assert.deepEqual(labels, ['Início', 'Descoberta', 'Realização', 'Suporte', 'Administração', 'Sobre nós', 'Mais']);
|
||||||
|
assert.equal((nav.match(/href="\/management\/editorial"/g) ?? []).length, 1);
|
||||||
|
assert.match(nav, /Dashboard/);
|
||||||
|
if (mobile) assert.match(nav, /<summary[^>]*>Dashboard<\/summary>/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user