@@ -35,7 +35,7 @@ const generalNavItems: NavItem[] = [
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
const { auth } = usePage().props;
|
||||
const { auth, payablesAvailable } = usePage().props;
|
||||
const canReadPurchases = [
|
||||
'admin',
|
||||
'procurement',
|
||||
@@ -155,6 +155,9 @@ export function AppSidebar() {
|
||||
href: '/facturas',
|
||||
icon: FileCheck2,
|
||||
},
|
||||
...(payablesAvailable === true
|
||||
? [{title: 'Obrigações a fornecedores', href: '/obrigacoes-fornecedores', icon: FileCheck2, fullPage: true}]
|
||||
: []),
|
||||
{
|
||||
title: 'Pagamentos externos',
|
||||
href: '/pagamentos',
|
||||
|
||||
@@ -39,10 +39,17 @@ export function NavMain({
|
||||
}
|
||||
tooltip={{ children: item.title }}
|
||||
>
|
||||
<Link href={item.href} prefetch>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
{item.fullPage ? (
|
||||
<a href={toUrl(item.href)}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
) : (
|
||||
<Link href={item.href} prefetch>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { UserMenuContent } from '@/components/user-menu-content';
|
||||
import { SgcWorkspaceShell } from '@/components/sgc-workspace-shell';
|
||||
|
||||
type Entry = { label: string; path: string; demo: string; roles?: string[] };
|
||||
const readers = ['admin', 'procurement', 'finance', 'department_head'];
|
||||
@@ -159,7 +160,7 @@ const groups: { label: string; entries: Entry[] }[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function SgcShell({
|
||||
function SgcPreviewShell({
|
||||
children,
|
||||
demo = false,
|
||||
}: {
|
||||
@@ -320,3 +321,17 @@ export function SgcShell({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SgcShell({
|
||||
children,
|
||||
demo = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
demo?: boolean;
|
||||
}) {
|
||||
return demo ? (
|
||||
<SgcPreviewShell demo>{children}</SgcPreviewShell>
|
||||
) : (
|
||||
<SgcWorkspaceShell>{children}</SgcWorkspaceShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
ChevronDown,
|
||||
LayoutDashboard,
|
||||
Menu,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { UserMenuContent } from '@/components/user-menu-content';
|
||||
import {
|
||||
activeProcurementEntry,
|
||||
procurementNavigation,
|
||||
} from '@/lib/procurement-navigation';
|
||||
import { roleLabels } from '@/types/procurement';
|
||||
|
||||
export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
const page = usePage();
|
||||
const user = page.props.auth?.user;
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const groups = procurementNavigation(
|
||||
user?.purchasing_role ?? '',
|
||||
page.props.payablesAvailable === true,
|
||||
);
|
||||
const activeId = activeProcurementEntry(groups, page.url);
|
||||
const activeGroup = groups.find((group) =>
|
||||
group.entries.some((entry) => entry.id === activeId),
|
||||
);
|
||||
const activeGroupId = activeGroup?.id;
|
||||
useEffect(() => {
|
||||
if (activeGroupId)
|
||||
setExpanded((previous) =>
|
||||
previous[activeGroupId]
|
||||
? previous
|
||||
: { ...previous, [activeGroupId]: true },
|
||||
);
|
||||
}, [activeId, activeGroupId]);
|
||||
const normalize = (text: string) =>
|
||||
text
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLocaleLowerCase('pt');
|
||||
const query = normalize(search.trim());
|
||||
const visibleGroups = groups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
entries: group.entries.filter(
|
||||
(entry) =>
|
||||
!query ||
|
||||
normalize(group.label + ' ' + entry.label).includes(query),
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.entries.length);
|
||||
const navigate = () => {
|
||||
setMobileOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
const brand = (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
onClick={navigate}
|
||||
className="flex items-center gap-3 px-5 py-6 focus-visible:outline-2 focus-visible:outline-offset-[-4px] focus-visible:outline-white"
|
||||
>
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-[#ed1c24] text-sm font-bold text-white">
|
||||
SGC
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-lg font-semibold tracking-tight text-white">
|
||||
Compras
|
||||
</span>
|
||||
<span className="block text-xs text-slate-400">
|
||||
Sistema de Gestão de Compras
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
const searchField = (mobile = false) => (
|
||||
<div
|
||||
className={`relative ${mobile ? 'mx-4 mb-4' : 'hidden w-full max-w-sm lg:block'}`}
|
||||
>
|
||||
<Search
|
||||
className="pointer-events-none absolute top-3 left-3 size-4 text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Procurar no menu"
|
||||
placeholder="Procurar no menu…"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className={`h-10 w-full rounded-lg border pr-9 pl-9 text-sm outline-none focus:border-red-400 focus:ring-2 focus:ring-red-400/20 ${mobile ? 'border-white/15 bg-white/5 text-white placeholder:text-slate-400' : 'border-border bg-background text-foreground'}`}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Limpar pesquisa do menu"
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute top-2 right-2 rounded p-1 text-slate-400 hover:text-red-400"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const navigation = (
|
||||
<nav
|
||||
aria-label="Navegação principal"
|
||||
className="min-h-0 flex-1 overflow-y-auto px-3 pb-5"
|
||||
>
|
||||
<p className="px-3 pt-2 pb-3 text-[10px] font-semibold tracking-[0.18em] text-slate-500">
|
||||
ESPAÇO DE TRABALHO
|
||||
</p>
|
||||
{(!query ||
|
||||
normalize('Painel inicial Visão geral').includes(query)) && (
|
||||
<Link
|
||||
href="/dashboard"
|
||||
onClick={navigate}
|
||||
aria-current={
|
||||
page.url.split('?')[0] === '/dashboard'
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
className={`mb-3 flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-white ${page.url.split('?')[0] === '/dashboard' ? 'bg-white/10 text-white' : 'text-slate-300 hover:bg-white/5 hover:text-white'}`}
|
||||
>
|
||||
<LayoutDashboard
|
||||
className="size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Painel inicial
|
||||
</Link>
|
||||
)}
|
||||
{visibleGroups.map((group) => (
|
||||
<details
|
||||
key={group.id}
|
||||
open={
|
||||
query
|
||||
? true
|
||||
: (expanded[group.id] ??
|
||||
activeGroup?.id === group.id)
|
||||
}
|
||||
onToggle={(event) => {
|
||||
if (!query) {
|
||||
const open = event.currentTarget.open;
|
||||
setExpanded((previous) =>
|
||||
previous[group.id] === open
|
||||
? previous
|
||||
: { ...previous, [group.id]: open },
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="group/menu mb-1"
|
||||
>
|
||||
<summary
|
||||
className={`flex cursor-pointer list-none items-center gap-3 rounded-lg px-3 py-2.5 text-[13px] font-medium transition-colors hover:bg-white/5 focus-visible:outline-2 focus-visible:outline-white [&::-webkit-details-marker]:hidden ${activeGroup?.id === group.id ? 'text-white' : 'text-slate-300'}`}
|
||||
>
|
||||
<group.icon
|
||||
className={`size-4 shrink-0 ${activeGroup?.id === group.id ? 'text-red-400' : 'text-slate-500'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="flex-1">{group.label}</span>
|
||||
<ChevronDown
|
||||
className="size-3.5 shrink-0 text-slate-500 transition-transform group-open/menu:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</summary>
|
||||
<ul className="mt-1 mb-3 ml-5 space-y-1 border-l border-white/10 pl-3">
|
||||
{group.entries.map((entry) => {
|
||||
const className = `block rounded-md px-3 py-2 text-[13px] leading-5 transition-colors focus-visible:outline-2 focus-visible:outline-white ${activeId === entry.id ? 'bg-red-500/15 font-medium text-red-300' : 'text-slate-400 hover:bg-white/5 hover:text-white'}`;
|
||||
return (
|
||||
<li key={entry.id}>
|
||||
{entry.fullPage ? (
|
||||
<a
|
||||
href={entry.path}
|
||||
onClick={navigate}
|
||||
className={className}
|
||||
aria-current={
|
||||
activeId === entry.id
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={entry.path}
|
||||
onClick={navigate}
|
||||
className={className}
|
||||
aria-current={
|
||||
activeId === entry.id
|
||||
? 'page'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</details>
|
||||
))}
|
||||
{query &&
|
||||
visibleGroups.length === 0 &&
|
||||
!normalize('Painel inicial Visão geral').includes(query) && (
|
||||
<p
|
||||
role="status"
|
||||
className="px-3 py-5 text-sm text-slate-400"
|
||||
>
|
||||
Nenhuma opção encontrada. Tenta outra palavra.
|
||||
</p>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
const sidebarFooter = (
|
||||
<div className="border-t border-white/10 px-5 py-4">
|
||||
<p className="text-[11px] text-slate-500">
|
||||
SGC · Espaço de trabalho
|
||||
</p>
|
||||
<Link
|
||||
href="/settings/appearance"
|
||||
onClick={navigate}
|
||||
className="mt-1 inline-block text-xs text-slate-400 hover:text-white"
|
||||
>
|
||||
Aparência
|
||||
</Link>
|
||||
<span className="mx-2 text-slate-700">·</span>
|
||||
<Link
|
||||
href="/"
|
||||
onClick={navigate}
|
||||
className="text-xs text-slate-400 hover:text-white"
|
||||
>
|
||||
Página inicial
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-foreground lg:grid lg:grid-cols-[17rem_minmax(0,1fr)] dark:bg-background print:block">
|
||||
<a
|
||||
href="#sgc-main"
|
||||
className="sr-only z-50 rounded bg-white p-3 text-black focus:not-sr-only focus:fixed focus:top-2 focus:left-2"
|
||||
>
|
||||
Saltar para o conteúdo
|
||||
</a>
|
||||
<aside className="sticky top-0 hidden h-screen flex-col border-r border-white/5 bg-[#101715] text-white lg:flex print:hidden">
|
||||
{brand}
|
||||
{navigation}
|
||||
{sidebarFooter}
|
||||
</aside>
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="flex w-[min(90vw,20rem)] max-w-none flex-col gap-0 border-white/10 bg-[#101715] p-0 text-white [&>button]:text-white"
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Menu de Compras</SheetTitle>
|
||||
<SheetDescription>
|
||||
Escolhe uma área da aplicação ou procura pelo nome.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{brand}
|
||||
{searchField(true)}
|
||||
{navigation}
|
||||
{sidebarFooter}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<header className="sticky top-0 z-20 flex min-h-18 items-center justify-between gap-4 border-b border-border bg-background/95 px-5 py-3 backdrop-blur lg:px-8 print:hidden">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Abrir menu de Compras"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="shrink-0 lg:hidden"
|
||||
>
|
||||
<Menu className="size-4" />
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold tracking-[0.15em] text-muted-foreground">
|
||||
COMPRAS
|
||||
</p>
|
||||
<p className="truncate text-sm font-medium">
|
||||
{page.url.split('?')[0] === '/dashboard'
|
||||
? 'Painel inicial'
|
||||
: (activeGroup?.label ??
|
||||
'Espaço de trabalho')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{searchField()}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="hidden bg-[#ed1c24] text-white hover:bg-[#d01720] sm:inline-flex"
|
||||
>
|
||||
<Link href="/requisicoes/criar">
|
||||
<Plus className="size-4" />
|
||||
Nova requisição
|
||||
</Link>
|
||||
</Button>
|
||||
{user && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={`Conta de ${user.name}`}
|
||||
className="h-auto max-w-52 gap-2 px-2 py-1.5"
|
||||
>
|
||||
<span
|
||||
className="grid size-8 shrink-0 place-items-center rounded-full bg-muted text-xs font-semibold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{user.name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join('')}
|
||||
</span>
|
||||
<span className="hidden min-w-0 text-left xl:block">
|
||||
<span className="block truncate text-xs font-medium">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="block text-[10px] text-muted-foreground">
|
||||
{roleLabels[
|
||||
user.purchasing_role ?? ''
|
||||
] ?? 'Utilizador'}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<UserMenuContent user={user} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main id="sgc-main" className="min-w-0 flex-1" tabIndex={-1}>
|
||||
{children}
|
||||
</main>
|
||||
<footer className="mt-auto border-t border-border px-5 py-4 text-xs text-muted-foreground lg:px-8 print:hidden">
|
||||
SGC · Sistema de Gestão de Compras
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
BadgeCheck,
|
||||
ClipboardList,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
Wallet,
|
||||
Warehouse,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export type ProcurementEntry = {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
roles?: string[];
|
||||
fullPage?: boolean;
|
||||
};
|
||||
export type ProcurementGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
entries: ProcurementEntry[];
|
||||
};
|
||||
const readers = ['admin', 'procurement', 'finance', 'department_head'];
|
||||
const finance = ['admin', 'finance'];
|
||||
const inventory = [...readers, 'warehouse'];
|
||||
|
||||
export function procurementNavigation(
|
||||
role: string,
|
||||
payables: boolean,
|
||||
): ProcurementGroup[] {
|
||||
const groups: ProcurementGroup[] = [
|
||||
{
|
||||
id: 'requests',
|
||||
label: 'Requisições',
|
||||
icon: ClipboardList,
|
||||
entries: [
|
||||
{
|
||||
id: 'new_request',
|
||||
label: 'Nova requisição',
|
||||
path: '/requisicoes/criar',
|
||||
},
|
||||
{
|
||||
id: 'my_requests',
|
||||
label: 'Minhas requisições',
|
||||
path: '/requisicoes?view=mine',
|
||||
},
|
||||
{
|
||||
id: 'my_drafts',
|
||||
label: 'Os meus rascunhos',
|
||||
path: '/requisicoes?view=mine&status=draft',
|
||||
},
|
||||
{
|
||||
id: 'all_requests',
|
||||
label: 'Todas as autorizadas',
|
||||
path: '/requisicoes?view=all',
|
||||
},
|
||||
{ id: 'planning', label: 'Planeamento', path: '/planeamento' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'approvals',
|
||||
label: 'Aprovações',
|
||||
icon: BadgeCheck,
|
||||
entries: [
|
||||
{
|
||||
id: 'pending_approvals',
|
||||
label: 'Pendentes para mim',
|
||||
path: '/requisicoes?view=approvals',
|
||||
roles: readers,
|
||||
},
|
||||
{
|
||||
id: 'decisions',
|
||||
label: 'Minhas decisões',
|
||||
path: '/requisicoes?view=decided',
|
||||
roles: readers,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sourcing',
|
||||
label: 'Consultas e adjudicações',
|
||||
icon: Scale,
|
||||
entries: [
|
||||
{
|
||||
id: 'quotations',
|
||||
label: 'Cotações e comparação',
|
||||
path: '/cotacoes',
|
||||
roles: readers,
|
||||
},
|
||||
{
|
||||
id: 'orders',
|
||||
label: 'Adjudicações e ordens',
|
||||
path: '/ordens',
|
||||
roles: readers,
|
||||
},
|
||||
{
|
||||
id: 'purchase_review',
|
||||
label: 'Revisão e cancelamento',
|
||||
path: '/revisao-compras',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'suppliers',
|
||||
label: 'Fornecedores',
|
||||
icon: Users,
|
||||
entries: [
|
||||
{
|
||||
id: 'suppliers',
|
||||
label: 'Cadastro e documentos',
|
||||
path: '/fornecedores',
|
||||
roles: readers,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'receiving',
|
||||
label: 'Recepções e armazém',
|
||||
icon: Warehouse,
|
||||
entries: [
|
||||
{
|
||||
id: 'receipts',
|
||||
label: 'Recepções e divergências',
|
||||
path: '/recepcoes',
|
||||
roles: inventory,
|
||||
},
|
||||
{
|
||||
id: 'stock',
|
||||
label: 'Artigos e stock',
|
||||
path: '/stock',
|
||||
roles: ['admin', 'procurement', 'finance', 'warehouse'],
|
||||
},
|
||||
{
|
||||
id: 'stock_operations',
|
||||
label: 'Saídas e inventários',
|
||||
path: '/stock/operacoes',
|
||||
roles: ['admin', 'procurement', 'finance', 'warehouse'],
|
||||
},
|
||||
{
|
||||
id: 'equipment',
|
||||
label: 'Equipamentos e peças',
|
||||
path: '/equipamentos',
|
||||
roles: inventory,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
label: 'Finanças',
|
||||
icon: Wallet,
|
||||
entries: [
|
||||
{
|
||||
id: 'invoices',
|
||||
label: 'Facturas e conferência',
|
||||
path: '/facturas',
|
||||
roles: finance,
|
||||
},
|
||||
...(payables
|
||||
? [
|
||||
{
|
||||
id: 'payables',
|
||||
label: 'Obrigações a fornecedores',
|
||||
path: '/obrigacoes-fornecedores',
|
||||
roles: finance,
|
||||
fullPage: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'payments',
|
||||
label: 'Pagamentos externos',
|
||||
path: '/pagamentos',
|
||||
roles: finance,
|
||||
},
|
||||
{
|
||||
id: 'budgets',
|
||||
label: 'Orçamentos e reforços',
|
||||
path: '/orcamentos',
|
||||
roles: finance,
|
||||
},
|
||||
{
|
||||
id: 'investment',
|
||||
label: 'Regras de investimento',
|
||||
path: '/regras-investimento',
|
||||
roles: ['admin', 'procurement', 'finance'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'administration',
|
||||
label: 'Administração e controlo',
|
||||
icon: ShieldCheck,
|
||||
entries: [
|
||||
{
|
||||
id: 'company',
|
||||
label: 'Empresa, equipa e centros',
|
||||
path: '/empresa',
|
||||
roles: ['admin'],
|
||||
},
|
||||
{
|
||||
id: 'admission_policy',
|
||||
label: 'Política de admissão',
|
||||
path: '/politica-admissao',
|
||||
roles: ['admin'],
|
||||
},
|
||||
{
|
||||
id: 'recommendation_policy',
|
||||
label: 'Política de recomendação',
|
||||
path: '/politica-recomendacao',
|
||||
roles: ['admin'],
|
||||
},
|
||||
{
|
||||
id: 'conflicts',
|
||||
label: 'Conflitos de interesses',
|
||||
path: '/conflitos',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
return groups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
entries: group.entries.filter(
|
||||
(entry) => !entry.roles || entry.roles.includes(role),
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.entries.length);
|
||||
}
|
||||
|
||||
export function activeProcurementEntry(
|
||||
groups: ProcurementGroup[],
|
||||
url: string,
|
||||
): string | undefined {
|
||||
const current = new URL(url, 'http://local');
|
||||
if (/^\/ordens\/\d+\/recepcoes/.test(current.pathname)) return 'receipts';
|
||||
if (/^\/requisicoes\/\d+\/adjudicacao(?:\/|$)/.test(current.pathname))
|
||||
return 'orders';
|
||||
if (
|
||||
/^\/requisicoes\/\d+\/(cotacoes|abertura|qualificacao|admissao|recomendacao|relatorio)(?:\/|$)/.test(
|
||||
current.pathname,
|
||||
)
|
||||
)
|
||||
return 'quotations';
|
||||
const matches = groups
|
||||
.flatMap((group) => group.entries)
|
||||
.filter((entry) => {
|
||||
const target = new URL(entry.path, 'http://local');
|
||||
if (
|
||||
current.pathname !== target.pathname &&
|
||||
!current.pathname.startsWith(target.pathname + '/')
|
||||
)
|
||||
return false;
|
||||
return [...target.searchParams].every(
|
||||
([key, value]) =>
|
||||
(current.searchParams.get(key) ??
|
||||
(key === 'view' ? 'all' : null)) === value,
|
||||
);
|
||||
});
|
||||
const specificity = (path: string) => {
|
||||
const target = new URL(path, 'http://local');
|
||||
return target.pathname.length * 100 + target.searchParams.size;
|
||||
};
|
||||
matches.sort((a, b) => specificity(b.path) - specificity(a.path));
|
||||
return matches[0]?.id;
|
||||
}
|
||||
@@ -48,10 +48,12 @@ export default function Invoices({
|
||||
invoices,
|
||||
orders,
|
||||
canManage,
|
||||
payablesAvailable = false,
|
||||
}: {
|
||||
invoices: Page<Invoice>;
|
||||
orders: { id: number; number: string; supplier: string; items: Source[] }[];
|
||||
canManage: boolean;
|
||||
payablesAvailable?: boolean;
|
||||
}) {
|
||||
const { auth } = usePage().props;
|
||||
const [orderId, setOrderId] = useState('');
|
||||
@@ -69,6 +71,7 @@ export default function Invoices({
|
||||
return (
|
||||
<>
|
||||
<Head title="Facturas" />
|
||||
{payablesAvailable && <a className="m-6 inline-block underline" href="/obrigacoes-fornecedores">Obrigações a fornecedores — ensaio sintético</a>}
|
||||
<div className="mx-auto grid w-full max-w-6xl gap-6 p-6">
|
||||
<PageTitle
|
||||
eyebrow="SGC · Finanças"
|
||||
@@ -282,6 +285,13 @@ export default function Invoices({
|
||||
{money(invoice.tax_minor)} · Total{' '}
|
||||
{money(invoice.total_minor)}
|
||||
</p>
|
||||
{payablesAvailable && (
|
||||
<p className="my-3">
|
||||
<a className="underline" href={`/obrigacoes-fornecedores?invoice=${invoice.id}`}>
|
||||
Consultar ou preparar obrigação desta factura
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<ul className="my-3 space-y-2">
|
||||
{invoice.items.map((l) => (
|
||||
<li key={l.id}>
|
||||
|
||||
@@ -20,21 +20,48 @@ type Props = {
|
||||
next_page_url: string | null;
|
||||
total: number;
|
||||
};
|
||||
filters: { search?: string; status?: string };
|
||||
filters: { search?: string; status?: string; view?: string };
|
||||
};
|
||||
export default function Index({ requisitions, filters }: Props) {
|
||||
const form = useForm({
|
||||
search: filters.search ?? '',
|
||||
status: filters.status ?? '',
|
||||
view: filters.view ?? 'all',
|
||||
});
|
||||
const views: Record<string, { title: string; description: string }> = {
|
||||
all: {
|
||||
title: 'Requisições autorizadas',
|
||||
description:
|
||||
'Consulte os pedidos disponíveis para o seu perfil e acompanhe o estado de cada processo.',
|
||||
},
|
||||
mine: {
|
||||
title:
|
||||
filters.status === 'draft'
|
||||
? 'Os meus rascunhos'
|
||||
: 'Minhas requisições',
|
||||
description:
|
||||
'Pedidos criados por si, desde o rascunho até à decisão.',
|
||||
},
|
||||
approvals: {
|
||||
title: 'Pendentes para mim',
|
||||
description:
|
||||
'Pedidos em que a próxima etapa pode ser decidida por si. A elegibilidade é conferida pelo servidor.',
|
||||
},
|
||||
decided: {
|
||||
title: 'Minhas decisões',
|
||||
description:
|
||||
'Histórico dos pedidos em que registou uma aprovação ou rejeição.',
|
||||
},
|
||||
};
|
||||
const view = views[filters.view ?? 'all'] ?? views.all;
|
||||
return (
|
||||
<>
|
||||
<Head title="Requisições" />
|
||||
<Head title={view.title} />
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
|
||||
<PageTitle
|
||||
eyebrow="Compras"
|
||||
title="Requisições"
|
||||
description="Registe necessidades e acompanhe cada pedido até à aprovação."
|
||||
title={view.title}
|
||||
description={view.description}
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/requisicoes/criar">
|
||||
@@ -52,6 +79,21 @@ export default function Index({ requisitions, filters }: Props) {
|
||||
form.get('/requisicoes', { preserveState: true });
|
||||
}}
|
||||
>
|
||||
<select
|
||||
className={`${controlClass} max-w-60`}
|
||||
aria-label="Conjunto de pedidos"
|
||||
value={form.data.view}
|
||||
onChange={(e) =>
|
||||
form.setData('view', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="all">Todas as autorizadas</option>
|
||||
<option value="mine">Minhas requisições</option>
|
||||
<option value="approvals">
|
||||
Pendentes para mim
|
||||
</option>
|
||||
<option value="decided">Minhas decisões</option>
|
||||
</select>
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
aria-label="Pesquisar pela referência"
|
||||
|
||||
@@ -11,4 +11,5 @@ export type NavItem = {
|
||||
href: NonNullable<InertiaLinkProps['href']>;
|
||||
icon?: LucideIcon | null;
|
||||
isActive?: boolean;
|
||||
fullPage?: boolean;
|
||||
};
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
</x-inertia::head>
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
@if(app()->environment('payable-testing'))
|
||||
<nav aria-label="Avaliação local" style="padding:10px 20px;background:#e1f1eb;color:#183d35;font:16px system-ui"><a href="/avaliar">Explorar Compras / acesso de proprietário</a> · Ambiente de testes</nav>
|
||||
@endif
|
||||
<x-inertia::app />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user