commit
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-28 12:06:05 +02:00
parent 90a6848721
commit 4d42f083ce
82 changed files with 7538 additions and 76 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ import AppLayout from '@/layouts/app-layout';
import AuthLayout from '@/layouts/auth-layout';
import SettingsLayout from '@/layouts/settings/layout';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const appName = 'ProcuraFlow';
void createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),
+1 -5
View File
@@ -1,10 +1,6 @@
import { usePage } from '@inertiajs/react';
import AppLogoIcon from '@/components/app-logo-icon';
export default function AppLogo() {
const { name } = usePage().props;
return (
<>
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground">
@@ -12,7 +8,7 @@ export default function AppLogo() {
</div>
<div className="ml-1 grid flex-1 text-left text-sm">
<span className="mb-0.5 truncate leading-tight font-semibold">
{name}
ProcuraFlow
</span>
</div>
</>
+39 -19
View File
@@ -1,7 +1,14 @@
import { Link } from '@inertiajs/react';
import { BookOpen, FolderGit2, LayoutGrid } from 'lucide-react';
import { Link, usePage } from '@inertiajs/react';
import {
Building2,
ClipboardList,
LayoutGrid,
Users,
Scale,
ShieldAlert,
FileCheck2,
} from 'lucide-react';
import AppLogo from '@/components/app-logo';
import { NavFooter } from '@/components/nav-footer';
import { NavMain } from '@/components/nav-main';
import { NavUser } from '@/components/nav-user';
import {
@@ -18,26 +25,40 @@ import type { NavItem } from '@/types';
const mainNavItems: NavItem[] = [
{
title: 'Dashboard',
title: 'Visão geral',
href: dashboard(),
icon: LayoutGrid,
},
];
const footerNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/react-starter-kit',
icon: FolderGit2,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#react',
icon: BookOpen,
},
{ title: 'Requisições', href: '/requisicoes', icon: ClipboardList },
{ title: 'Conflitos de interesses', href: '/conflitos', icon: ShieldAlert },
];
export function AppSidebar() {
const { auth } = usePage().props;
const purchasingItems = [
'admin',
'procurement',
'finance',
'department_head',
].includes(auth.user.purchasing_role ?? '')
? [
...mainNavItems,
{ title: 'Fornecedores', href: '/fornecedores', icon: Users },
{ title: 'Cotações', href: '/cotacoes', icon: Scale },
{ title: 'Ordens de compra', href: '/ordens', icon: FileCheck2 },
]
: mainNavItems;
const items =
auth.user.purchasing_role === 'admin'
? [
...purchasingItems,
{
title: 'Empresa e equipa',
href: '/empresa',
icon: Building2,
},
]
: purchasingItems;
return (
<Sidebar collapsible="icon" variant="inset">
<SidebarHeader>
@@ -53,11 +74,10 @@ export function AppSidebar() {
</SidebarHeader>
<SidebarContent>
<NavMain items={mainNavItems} />
<NavMain items={items} />
</SidebarContent>
<SidebarFooter>
<NavFooter items={footerNavItems} className="mt-auto" />
<NavUser />
</SidebarFooter>
</Sidebar>
+161
View File
@@ -0,0 +1,161 @@
import { Link } from '@inertiajs/react';
import { ArrowUpRight, ClipboardList } from 'lucide-react';
import type { ReactNode } from 'react';
import { Badge } from '@/components/ui/badge';
import { date, money, statusLabels } from '@/types/procurement';
import type { Requisition } from '@/types/procurement';
export const controlClass =
'h-10 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
export function PageTitle({
eyebrow,
title,
description,
action,
}: {
eyebrow: string;
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="mb-2 text-xs font-semibold tracking-widest text-teal-700 uppercase dark:text-teal-400">
{eyebrow}
</p>
<h1 className="text-3xl font-semibold tracking-tight">
{title}
</h1>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
{description}
</p>
</div>
{action}
</div>
);
}
export function Panel({
title,
description,
children,
}: {
title: string;
description?: string;
children: ReactNode;
}) {
return (
<section className="rounded-xl border bg-card p-5 shadow-xs">
<h2 className="font-semibold">{title}</h2>
{description && (
<p className="mt-1 text-sm text-muted-foreground">
{description}
</p>
)}
<div className="mt-5">{children}</div>
</section>
);
}
export function Status({ value }: { value: string }) {
return (
<Badge
variant="outline"
className={
value === 'approved'
? 'border-teal-200 bg-teal-50 text-teal-800 dark:bg-teal-950 dark:text-teal-200'
: value === 'pending'
? 'border-amber-200 bg-amber-50 text-amber-800 dark:bg-amber-950 dark:text-amber-200'
: value === 'rejected'
? 'border-rose-200 bg-rose-50 text-rose-800 dark:bg-rose-950 dark:text-rose-200'
: ''
}
>
{statusLabels[value] ?? value}
</Badge>
);
}
export function RequisitionTable({ rows }: { rows: Requisition[] }) {
if (!rows.length)
return (
<div className="py-12 text-center">
<ClipboardList className="mx-auto mb-3 size-9 text-muted-foreground/50" />
<p className="font-medium">Sem requisições para apresentar</p>
<p className="mt-1 text-sm text-muted-foreground">
Os pedidos de compra aparecem aqui depois de criados.
</p>
</div>
);
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b text-xs text-muted-foreground">
<tr>
<th className="pb-3 font-medium">Requisição</th>
<th className="pb-3 font-medium">Departamento</th>
<th className="pb-3 font-medium">Data</th>
<th className="pb-3 font-medium">Estado</th>
<th className="pb-3 text-right font-medium">
Total com IVA
</th>
<th>
<span className="sr-only">Abrir</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b last:border-0">
<td className="py-4 pr-4">
<Link
className="font-semibold hover:text-teal-700"
href={`/requisicoes/${r.id}`}
>
{r.number}
</Link>
<div className="mt-1 text-xs text-muted-foreground">
{r.requester?.name}
</div>
</td>
<td className="pr-4">
{r.department?.name ?? '—'}
</td>
<td className="pr-4 whitespace-nowrap">
{date(r.created_at)}
</td>
<td className="pr-4">
<Status value={r.status} />
</td>
<td className="text-right font-medium whitespace-nowrap">
{money(Number(r.total_minor))}
</td>
<td className="pl-3">
<Link
href={`/requisicoes/${r.id}`}
aria-label={`Abrir ${r.number}`}
>
<ArrowUpRight className="size-4" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function FormErrors({ errors }: { errors: Record<string, string> }) {
if (!Object.keys(errors).length) return null;
return (
<div
role="alert"
className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"
>
<p className="mb-1 font-medium">Verifique os dados:</p>
<ul className="list-inside list-disc">
{Object.entries(errors).map(([key, value]) => (
<li key={key}>{value}</li>
))}
</ul>
</div>
);
}
+13 -11
View File
@@ -19,7 +19,7 @@ type Props = {
export default function Login({ status, canResetPassword }: Props) {
return (
<>
<Head title="Log in" />
<Head title="Entrar" />
<Form
{...store.form()}
@@ -30,7 +30,7 @@ export default function Login({ status, canResetPassword }: Props) {
<>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Label htmlFor="email">Endereço de email</Label>
<Input
id="email"
type="email"
@@ -46,14 +46,16 @@ export default function Login({ status, canResetPassword }: Props) {
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">
Palavra-passe
</Label>
{canResetPassword && (
<TextLink
href={request()}
className="ml-auto text-sm"
tabIndex={5}
>
Forgot your password?
Esqueceu a palavra-passe?
</TextLink>
)}
</div>
@@ -63,7 +65,7 @@ export default function Login({ status, canResetPassword }: Props) {
required
tabIndex={2}
autoComplete="current-password"
placeholder="Password"
placeholder="Palavra-passe"
/>
<InputError message={errors.password} />
</div>
@@ -74,7 +76,7 @@ export default function Login({ status, canResetPassword }: Props) {
name="remember"
tabIndex={3}
/>
<Label htmlFor="remember">Remember me</Label>
<Label htmlFor="remember">Manter sessão</Label>
</div>
<Button
@@ -85,14 +87,14 @@ export default function Login({ status, canResetPassword }: Props) {
data-test="login-button"
>
{processing && <Spinner />}
Log in
Entrar
</Button>
</div>
<div className="text-center text-sm text-muted-foreground">
Don't have an account?{' '}
Ainda não tem conta?{' '}
<TextLink href={register()} tabIndex={5}>
Sign up
Criar conta
</TextLink>
</div>
</>
@@ -109,6 +111,6 @@ export default function Login({ status, canResetPassword }: Props) {
}
Login.layout = {
title: 'Log in to your account',
description: 'Enter your email and password below to log in',
title: 'Entrar no ProcuraFlow',
description: 'Introduza o seu email e a palavra-passe',
};
+13 -13
View File
@@ -16,7 +16,7 @@ type Props = {
export default function Register({ passwordRules }: Props) {
return (
<>
<Head title="Register" />
<Head title="Registar" />
<Form
{...store.form()}
resetOnSuccess={['password', 'password_confirmation']}
@@ -27,7 +27,7 @@ export default function Register({ passwordRules }: Props) {
<>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Label htmlFor="name">Nome</Label>
<Input
id="name"
type="text"
@@ -36,7 +36,7 @@ export default function Register({ passwordRules }: Props) {
tabIndex={1}
autoComplete="name"
name="name"
placeholder="Full name"
placeholder="Nome completo"
/>
<InputError
message={errors.name}
@@ -45,7 +45,7 @@ export default function Register({ passwordRules }: Props) {
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Label htmlFor="email">Endereço de email</Label>
<Input
id="email"
type="email"
@@ -59,14 +59,14 @@ export default function Register({ passwordRules }: Props) {
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">Palavra-passe</Label>
<PasswordInput
id="password"
required
tabIndex={3}
autoComplete="new-password"
name="password"
placeholder="Password"
placeholder="Palavra-passe"
passwordrules={passwordRules}
/>
<InputError message={errors.password} />
@@ -74,7 +74,7 @@ export default function Register({ passwordRules }: Props) {
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm password
Confirmar palavra-passe
</Label>
<PasswordInput
id="password_confirmation"
@@ -82,7 +82,7 @@ export default function Register({ passwordRules }: Props) {
tabIndex={4}
autoComplete="new-password"
name="password_confirmation"
placeholder="Confirm password"
placeholder="Confirmar palavra-passe"
passwordrules={passwordRules}
/>
<InputError
@@ -97,14 +97,14 @@ export default function Register({ passwordRules }: Props) {
data-test="register-user-button"
>
{processing && <Spinner />}
Create account
Criar conta
</Button>
</div>
<div className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
Já tem conta?{' '}
<TextLink href={login()} tabIndex={6}>
Log in
Entrar
</TextLink>
</div>
</>
@@ -115,6 +115,6 @@ export default function Register({ passwordRules }: Props) {
}
Register.layout = {
title: 'Create an account',
description: 'Enter your details below to create your account',
title: 'Criar conta',
description: 'Registe a conta para configurar a sua empresa',
};
+371
View File
@@ -0,0 +1,371 @@
import { Head, useForm } from '@inertiajs/react';
import {
FormErrors,
PageTitle,
Panel,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { money, roleLabels } from '@/types/procurement';
import type { Company, Department, CostCenter } from '@/types/procurement';
type Member = {
id: number;
name: string;
email: string;
department_id: number;
purchasing_role: string;
};
type Props = {
company: Company;
departments: Department[];
costCenters: CostCenter[];
users: Member[];
roles: { value: string; label: string }[];
};
export default function CompanyPage({
company,
departments,
costCenters,
users,
roles,
}: Props) {
const department = useForm({ name: '' });
const center = useForm({ department_id: '', code: '', name: '' });
const user = useForm({
name: '',
email: '',
department_id: '',
purchasing_role: 'requester',
password: '',
password_confirmation: '',
});
return (
<>
<Head title="Empresa e equipa" />
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow="Administração"
title={company.name}
description="Organize os departamentos, centros de custo e contas da equipa."
/>
<div className="grid gap-5 lg:grid-cols-2">
<Panel
title="Departamentos"
description="Cada chefe aprova apenas pedidos do seu departamento."
>
<div className="mb-5 flex flex-wrap gap-2">
{departments.map((d) => (
<span
className="rounded-md bg-muted px-3 py-1 text-sm"
key={d.id}
>
{d.name}
</span>
))}
</div>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
department.post('/empresa/departamentos', {
onSuccess: () => department.reset(),
});
}}
>
<FormErrors errors={department.errors} />
<Label htmlFor="department-name">
Novo departamento
</Label>
<div className="flex gap-2">
<Input
id="department-name"
required
value={department.data.name}
onChange={(e) =>
department.setData(
'name',
e.target.value,
)
}
/>
<Button disabled={department.processing}>
Adicionar
</Button>
</div>
</form>
</Panel>
<Panel title="Centros de custo">
<div className="mb-5 space-y-2 text-sm">
{costCenters.map((c) => (
<p key={c.id}>
<strong>{c.code}</strong> · {c.name}
<span className="ml-2 text-muted-foreground">
{
departments.find(
(d) => d.id === c.department_id,
)?.name
}
</span>
</p>
))}
</div>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
center.post('/empresa/centros-de-custo', {
onSuccess: () => center.reset(),
});
}}
>
<FormErrors errors={center.errors} />
<Label htmlFor="center-department">
Departamento
</Label>
<select
id="center-department"
className={controlClass}
required
value={center.data.department_id}
onChange={(e) =>
center.setData(
'department_id',
e.target.value,
)
}
>
<option value="">Seleccione</option>
{departments.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
<div className="grid grid-cols-2 gap-3">
{(['code', 'name'] as const).map((key) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`center-${key}`}>
{key === 'code'
? 'Código'
: 'Designação'}
</Label>
<Input
id={`center-${key}`}
required
value={center.data[key]}
onChange={(e) =>
center.setData(
key,
e.target.value,
)
}
/>
</div>
))}
</div>
<Button disabled={center.processing}>
Adicionar centro de custo
</Button>
</form>
</Panel>
</div>
<Panel
title="Equipa"
description="O perfil define as operações disponíveis. Um administrador não substitui os aprovadores."
>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b text-muted-foreground">
<tr>
<th className="pb-3">Nome</th>
<th>Email</th>
<th>Departamento</th>
<th>Perfil</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr
key={u.id}
className="border-b last:border-0"
>
<td className="py-3 pr-4 font-medium">
{u.name}
</td>
<td className="pr-4">{u.email}</td>
<td className="pr-4">
{
departments.find(
(d) =>
d.id ===
u.department_id,
)?.name
}
</td>
<td>{roleLabels[u.purchasing_role]}</td>
</tr>
))}
</tbody>
</table>
</div>
<form
className="mt-6 space-y-4 border-t pt-5"
autoComplete="off"
onSubmit={(e) => {
e.preventDefault();
user.post('/empresa/utilizadores', {
onSuccess: () => user.reset(),
});
}}
>
<h3 className="text-sm font-semibold">
Adicionar utilizador
</h3>
<FormErrors errors={user.errors} />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{(
[
{
key: 'name',
label: 'Nome',
type: 'text',
},
{
key: 'email',
label: 'Email',
type: 'email',
},
{
key: 'password',
label: 'Palavra-passe inicial',
type: 'password',
},
{
key: 'password_confirmation',
label: 'Confirmar palavra-passe',
type: 'password',
},
] as const
).map((field) => (
<div className="space-y-2" key={field.key}>
<Label htmlFor={`member-${field.key}`}>
{field.label}
</Label>
<Input
id={`member-${field.key}`}
type={field.type}
autoComplete={
field.type === 'password'
? 'new-password'
: 'off'
}
minLength={
field.type === 'password'
? 12
: undefined
}
required
value={user.data[field.key]}
onChange={(e) =>
user.setData(
field.key,
e.target.value,
)
}
/>
</div>
))}
<div className="space-y-2">
<Label htmlFor="member-department">
Departamento
</Label>
<select
id="member-department"
className={controlClass}
required
value={user.data.department_id}
onChange={(e) =>
user.setData(
'department_id',
e.target.value,
)
}
>
<option value="">Seleccione</option>
{departments.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="member-role">Perfil</Label>
<select
id="member-role"
className={controlClass}
value={user.data.purchasing_role}
onChange={(e) =>
user.setData(
'purchasing_role',
e.target.value,
)
}
>
{roles.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</div>
</div>
<p className="text-xs text-muted-foreground">
Palavras-passe com pelo menos 12 caracteres. Nesta
etapa as contas são criadas pelo administrador;
convites por email ficam para uma próxima etapa.
</p>
<Button disabled={user.processing}>
Criar utilizador
</Button>
</form>
</Panel>
<Panel
title="Política inicial de aprovação"
description="Configuração provisória. A alteração com dupla validação administrativa será implementada numa próxima etapa."
>
<div className="grid gap-4 text-sm md:grid-cols-3">
<p>
Até {money(company.first_limit_minor)}
<br />
<strong>Chefe de departamento</strong>
</p>
<p>
Até {money(company.second_limit_minor)}
<br />
<strong>Chefe + Procurement</strong>
</p>
<p>
Acima de {money(company.second_limit_minor)}
<br />
<strong>Chefe + Procurement + Finanças</strong>
</p>
</div>
<p className="mt-4 text-sm text-muted-foreground">
Base de cálculo:{' '}
{company.approval_includes_tax ? 'com' : 'sem'} IVA.
Cada etapa exige uma pessoa distinta do solicitante e
dos aprovadores anteriores.
</p>
</Panel>
</div>
</>
);
}
CompanyPage.layout = {
breadcrumbs: [{ title: 'Empresa e equipa', href: '/empresa' }],
};
+71
View File
@@ -0,0 +1,71 @@
import { Head, useForm } from '@inertiajs/react';
import { FormErrors, PageTitle, Panel } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export default function Setup() {
const form = useForm({ name: '', department: '' });
return (
<>
<Head title="Configurar empresa" />
<div className="mx-auto flex w-full max-w-2xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow="Primeiros passos"
title="A sua empresa"
description="Crie a organização e o departamento inicial. A sua conta ficará como administradora desta empresa."
/>
<Panel title="Dados da organização">
<form
onSubmit={(e) => {
e.preventDefault();
form.post('/empresa/iniciar');
}}
className="space-y-5"
>
<FormErrors errors={form.errors} />
<div className="space-y-2">
<Label htmlFor="company-name">
Nome da empresa
</Label>
<Input
id="company-name"
required
maxLength={150}
value={form.data.name}
onChange={(e) =>
form.setData('name', e.target.value)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="department">
Primeiro departamento
</Label>
<Input
id="department"
required
maxLength={100}
value={form.data.department}
onChange={(e) =>
form.setData('department', e.target.value)
}
placeholder="Ex.: Administração"
/>
</div>
<p className="text-sm text-muted-foreground">
Será criado um centro de custo geral. Moeda inicial:
MZN. Regras de aprovação provisórias.
</p>
<Button disabled={form.processing}>
Criar empresa
</Button>
</form>
</Panel>
</div>
</>
);
}
Setup.layout = {
breadcrumbs: [{ title: 'Configurar empresa', href: '/empresa/iniciar' }],
};
+307
View File
@@ -0,0 +1,307 @@
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import {
FormErrors,
PageTitle,
Panel,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { conflictScope } from '@/types/conflict';
import type { ConflictDeclaration } from '@/types/conflict';
import { date } from '@/types/procurement';
type Props = {
declarations: {
data: ConflictDeclaration[];
prev_page_url: string | null;
next_page_url: string | null;
};
suppliers: { id: number; name: string }[];
requisitions: { id: number; number: string }[];
};
export default function Conflicts({
declarations,
suppliers,
requisitions,
}: Props) {
const { auth } = usePage().props;
const form = useForm({
scope: 'supplier',
supplier_id: '',
requisition_id: '',
has_conflict: '1',
known_since: '',
reason: '',
});
return (
<>
<Head title="Conflitos de interesses" />
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow="Integridade nas compras · RF-AF-01"
title="Conflitos de interesses"
description="Registe a sua situação pessoal perante um fornecedor ou processo. Administração e Finanças podem consultar o registo da empresa; os restantes perfis consultam apenas as suas declarações."
/>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
Um conflito registado mantém o impedimento, mesmo que exista
uma declaração posterior de ausência. Não há caducidade
automática nem retirada de conflitos nesta etapa: essas
condições dependem da política da empresa. A data de
conhecimento descreve o facto e não adia o bloqueio.
</div>
<Panel
title="Nova declaração pessoal"
description={`Autor: ${auth.user.name}. A data e a hora do registo são guardadas pelo servidor.`}
>
<form
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
form.transform((data) => ({
...data,
has_conflict: data.has_conflict === '1',
supplier_id:
data.scope === 'process'
? null
: data.supplier_id || null,
requisition_id:
data.scope === 'supplier'
? null
: data.requisition_id || null,
}));
form.post('/conflitos', {
preserveScroll: true,
onSuccess: () => form.reset(),
});
}}
>
<FormErrors errors={form.errors} />
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="conflict-scope">Âmbito</Label>
<select
id="conflict-scope"
className={controlClass}
value={form.data.scope}
onChange={(e) =>
form.setData('scope', e.target.value)
}
>
<option value="supplier">
Fornecedor, em qualquer processo
</option>
<option value="process">
Processo, independentemente do
fornecedor
</option>
<option value="process_supplier">
Fornecedor neste processo
</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="conflict-result">
Situação declarada
</Label>
<select
id="conflict-result"
className={controlClass}
value={form.data.has_conflict}
onChange={(e) =>
form.setData(
'has_conflict',
e.target.value,
)
}
>
<option value="1">
Existe conflito de interesses
</option>
<option value="0">
Não identifico conflito de interesses
</option>
</select>
</div>
{form.data.scope !== 'process' && (
<div className="space-y-2">
<Label htmlFor="conflict-supplier">
Fornecedor
</Label>
<select
id="conflict-supplier"
className={controlClass}
required
value={form.data.supplier_id}
onChange={(e) =>
form.setData(
'supplier_id',
e.target.value,
)
}
>
<option value="">Seleccione</option>
{suppliers.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
)}
{form.data.scope !== 'supplier' && (
<div className="space-y-2">
<Label htmlFor="conflict-process">
Processo / requisição
</Label>
<select
id="conflict-process"
className={controlClass}
required
value={form.data.requisition_id}
onChange={(e) =>
form.setData(
'requisition_id',
e.target.value,
)
}
>
<option value="">Seleccione</option>
{requisitions.map((r) => (
<option key={r.id} value={r.id}>
{r.number}
</option>
))}
</select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="conflict-known">
Data de conhecimento da situação
</Label>
<Input
id="conflict-known"
type="date"
required
value={form.data.known_since}
onChange={(e) =>
form.setData(
'known_since',
e.target.value,
)
}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="conflict-reason">
Fundamento da declaração
</Label>
<textarea
id="conflict-reason"
className={`${controlClass} h-28 py-3`}
required
minLength={10}
maxLength={5000}
value={form.data.reason}
onChange={(e) =>
form.setData('reason', e.target.value)
}
/>
</div>
<Button disabled={form.processing}>
Registar declaração pessoal
</Button>
</form>
</Panel>
<Panel
title="Declarações e correcções"
description="O histórico é preservado. Só o autor pode corrigir o fundamento e a data de conhecimento, sem mudar o âmbito ou retirar o conflito."
>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b text-muted-foreground">
<tr>
<th className="pr-4 pb-3">
Autor e registo
</th>
<th className="pr-4">Âmbito</th>
<th className="pr-4">Situação</th>
<th>
<span className="sr-only">
Histórico
</span>
</th>
</tr>
</thead>
<tbody>
{declarations.data.map((d) => (
<tr
key={d.id}
className="border-b last:border-0"
>
<td className="py-4 pr-4">
<p className="font-medium">
{d.author.name}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{date(d.declared_at)} · #{d.id}
{d.previous_id
? ` · Correcção de #${d.previous_id}`
: ''}
</p>
</td>
<td className="pr-4">
{conflictScope(d)}
</td>
<td className="pr-4">
{d.has_conflict
? 'Conflito registado'
: 'Ausência declarada'}
{d.successor && (
<p className="text-xs text-muted-foreground">
Com correcção posterior
</p>
)}
</td>
<td>
<Link
className="text-teal-700 dark:text-teal-400"
href={`/conflitos/${d.id}`}
>
Ver histórico
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
{declarations.data.length === 0 && (
<p className="py-6 text-sm text-muted-foreground">
Ainda não existem declarações disponíveis para o seu
perfil.
</p>
)}
<div className="mt-4 flex gap-4 text-sm">
{declarations.prev_page_url && (
<Link href={declarations.prev_page_url}>
Anterior
</Link>
)}
{declarations.next_page_url && (
<Link href={declarations.next_page_url}>
Seguinte
</Link>
)}
</div>
</Panel>
</div>
</>
);
}
Conflicts.layout = {
breadcrumbs: [{ title: 'Conflitos de interesses', href: '/conflitos' }],
};
+137
View File
@@ -0,0 +1,137 @@
import { Head, Link, useForm } from '@inertiajs/react';
import {
FormErrors,
PageTitle,
Panel,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { conflictScope } from '@/types/conflict';
import type { ConflictDeclaration } from '@/types/conflict';
import { date } from '@/types/procurement';
export default function DeclarationHistory({
history,
canCorrect,
}: {
history: ConflictDeclaration[];
canCorrect: boolean;
}) {
const current = history[history.length - 1];
const form = useForm({
reason: current.reason,
known_since: current.known_since.slice(0, 10),
});
return (
<>
<Head title="Histórico da declaração" />
<div className="mx-auto flex w-full max-w-4xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow="Conflitos de interesses"
title="Histórico da declaração"
description={`${current.author.name} · ${conflictScope(current)}`}
action={
<Button asChild variant="outline">
<Link href="/conflitos">Voltar às declarações</Link>
</Button>
}
/>
<Panel
title={
current.has_conflict
? 'Conflito registado com impedimento'
: 'Ausência de conflito declarada'
}
description="Uma ausência declarada não retira outros conflitos registados. Não há caducidade ou levantamento automático."
>
<ol className="space-y-5">
{history.map((d) => (
<li
className="border-l-2 border-teal-600/40 pl-4"
key={d.id}
>
<p className="text-sm font-semibold">
Registo #{d.id}
{d.previous_id
? ` · Correcção de #${d.previous_id}`
: ' · Declaração inicial'}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{d.author.name} · Registado em{' '}
{date(d.declared_at)} às{' '}
{d.declared_at.slice(11, 19)} UTC ·
Conhecimento em {date(d.known_since)}
</p>
<p className="mt-3 text-sm whitespace-pre-wrap">
{d.reason}
</p>
</li>
))}
</ol>
</Panel>
{canCorrect && (
<Panel
title="Corrigir fundamento ou data de conhecimento"
description="A correcção cria um novo registo e mantém os anteriores. O autor, âmbito e situação declarada permanecem iguais."
>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.put(`/conflitos/${current.id}`, {
preserveScroll: true,
});
}}
>
<FormErrors errors={form.errors} />
<div className="space-y-2">
<Label htmlFor="correct-date">
Data de conhecimento
</Label>
<Input
id="correct-date"
type="date"
required
value={form.data.known_since}
onChange={(e) =>
form.setData(
'known_since',
e.target.value,
)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="correct-reason">
Fundamento corrigido
</Label>
<textarea
id="correct-reason"
className={`${controlClass} h-28 py-3`}
required
minLength={10}
maxLength={5000}
value={form.data.reason}
onChange={(e) =>
form.setData('reason', e.target.value)
}
/>
</div>
<Button disabled={form.processing}>
Registar correcção no histórico
</Button>
</form>
</Panel>
)}
</div>
</>
);
}
DeclarationHistory.layout = {
breadcrumbs: [
{ title: 'Conflitos de interesses', href: '/conflitos' },
{ title: 'Histórico', href: '#' },
],
};
+177 -22
View File
@@ -1,26 +1,181 @@
import { Head } from '@inertiajs/react';
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
import { dashboard } from '@/routes';
import { Head, Link } from '@inertiajs/react';
import {
ArrowRight,
CheckCheck,
ClipboardList,
Clock3,
Plus,
Wallet,
} from 'lucide-react';
import { PageTitle, Panel, RequisitionTable } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { money } from '@/types/procurement';
import type { Company, Requisition } from '@/types/procurement';
export default function Dashboard() {
type Props = {
company: Company | null;
stats: {
total: number;
pending: number;
approved: number;
approvedMinor: number;
awaitingMe: number;
};
recent: Requisition[];
approvals: Requisition[];
};
export default function Dashboard({
company,
stats,
recent,
approvals,
}: Props) {
const cards = [
{
label: 'Requisições',
value: stats.total,
icon: ClipboardList,
detail: 'Total de pedidos visíveis',
},
{
label: 'Em aprovação',
value: stats.pending,
icon: Clock3,
detail: `${stats.awaitingMe} aguardam a sua decisão`,
},
{
label: 'Aprovadas',
value: stats.approved,
icon: CheckCheck,
detail: 'Todas as etapas concluídas',
},
{
label: 'Valor aprovado',
value: money(Number(stats.approvedMinor)),
icon: Wallet,
detail: 'Estimativa em MZN, com IVA',
},
];
return (
<>
<Head title="Dashboard" />
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
</div>
<div className="relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border">
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
</div>
<Head title="Visão geral" />
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow={company?.name ?? 'Bem-vindo ao ProcuraFlow'}
title="Visão geral das compras"
description="Acompanhe os pedidos, as aprovações e a actividade da sua empresa."
action={
<Button
asChild
className="bg-teal-700 text-white hover:bg-teal-800"
>
<Link
href={
company
? '/requisicoes/criar'
: '/empresa/iniciar'
}
>
<Plus className="size-4" />
{company
? 'Nova requisição'
: 'Configurar empresa'}
</Link>
</Button>
}
/>
{!company ? (
<Panel
title="Vamos configurar a sua empresa"
description="Crie a organização e o primeiro departamento. Depois pode adicionar a equipa e começar a registar pedidos."
>
<Button asChild>
<Link href="/empresa/iniciar">
Começar configuração{' '}
<ArrowRight className="size-4" />
</Link>
</Button>
</Panel>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{cards.map((card) => (
<div
key={card.label}
className="rounded-xl border bg-card p-5 shadow-xs"
>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{card.label}</span>
<card.icon className="size-4 text-teal-700 dark:text-teal-400" />
</div>
<p className="mt-5 text-2xl font-semibold tracking-tight">
{card.value}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{card.detail}
</p>
</div>
))}
</div>
{company.rules_provisional && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
<strong>
Ambiente de desenvolvimento · regras
provisórias.
</strong>{' '}
Escalões de {money(company.first_limit_minor)} e{' '}
{money(company.second_limit_minor)}, calculados{' '}
{company.approval_includes_tax ? 'com' : 'sem'}{' '}
IVA. Os parâmetros precisam de validação antes
de compras reais.
</div>
)}
{approvals.length > 0 && (
<Panel
title="À espera da sua aprovação"
description="Abra um pedido para conferir os artigos e registar a decisão."
>
<div className="divide-y">
{approvals.map((r) => (
<Link
key={r.id}
href={`/requisicoes/${r.id}`}
className="flex items-center justify-between gap-4 py-3"
>
<div>
<p className="font-medium">
{r.number}
</p>
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
{r.justification}
</p>
</div>
<span className="flex shrink-0 items-center gap-3 text-sm font-medium">
{money(r.total_minor)}
<ArrowRight className="size-4" />
</span>
</Link>
))}
</div>
</Panel>
)}
<Panel
title="Requisições recentes"
description="Últimos pedidos disponíveis para o seu perfil."
>
<RequisitionTable rows={recent} />
<div className="mt-4 border-t pt-4">
<Link
href="/requisicoes"
className="inline-flex items-center gap-2 text-sm font-medium text-teal-700 dark:text-teal-400"
>
Ver todas as requisições{' '}
<ArrowRight className="size-4" />
</Link>
</div>
</Panel>
</>
)}
</div>
</>
);
@@ -29,8 +184,8 @@ export default function Dashboard() {
Dashboard.layout = {
breadcrumbs: [
{
title: 'Dashboard',
href: dashboard(),
title: 'Visão geral',
href: '/dashboard',
},
],
};
+26
View File
@@ -0,0 +1,26 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { FormErrors, PageTitle, Panel, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { money, roleLabels, date } from '@/types/procurement';
import type { MatrixRow, PurchaseAward } from '@/types/purchase';
type Props = {
requisition: { id: number; number: string }; comparison: { rows: MatrixRow[]; ready: boolean; top_ids: number[] };
award: PurchaseAward | null; canSelect: boolean; canDecide: boolean; canIssue: boolean; order: { id: number; number: string } | null;
};
export default function Award({ requisition, comparison, award, canSelect, canDecide, canIssue, order }: Props) {
const selection = useForm({ quotation_id: '', justification: '', delivery_date: '', delivery_location: '' });
const decision = useForm({ decision: 'approved', comment: '' });
const issue = useForm({});
return <><Head title={`Adjudicação · ${requisition.number}`} /><div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8"><PageTitle eyebrow={requisition.number} title="Adjudicação e emissão" description="Escolha fundamentada da proposta de maior pontuação, seguida de aprovação explícita do valor final." action={<Button asChild variant="outline"><Link href={`/requisicoes/${requisition.id}/cotacoes`}>Consultar cotações</Link></Button>} />
<div className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">Ambiente de desenvolvimento: os documentos não autorizam compras reais. O orçamento ainda não é verificado. Excepções às três propostas ou à maior pontuação permanecem bloqueadas enquanto não existir política aprovada.</div>
<Panel title="Comparação actual" description={comparison.ready ? 'Matriz completa para análise. O servidor volta a verificar tudo em cada operação.' : 'A matriz ainda não reúne três propostas elegíveis, cada uma com duas avaliações elegíveis.'}><div className="overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b text-muted-foreground"><tr><th className="pb-3 pr-4">Fornecedor</th><th className="pr-4">Total com IVA</th><th className="pr-4">Avaliações elegíveis</th><th>Pontuação</th></tr></thead><tbody>{comparison.rows.map(q => <tr key={q.id} className="border-b last:border-0"><td className="py-4 pr-4"><p className="font-medium">{q.supplier?.name} · {q.reference}</p>{q.ineligibility_reasons.map(reason => <p className="mt-1 text-xs text-destructive" key={reason}>{reason}</p>)}</td><td className="pr-4 whitespace-nowrap">{money(q.total_minor)}</td><td className="pr-4">{q.evaluation_count} / 2</td><td>{q.score === null ? 'Pendente' : `${q.score.toFixed(2)} / 10`}{comparison.top_ids.includes(q.id) && <p className="text-xs font-medium text-teal-700 dark:text-teal-400">Maior pontuação</p>}</td></tr>)}</tbody></table></div></Panel>
{canSelect && <Panel title="Seleccionar proposta" description="Empates permitem escolha fundamentada entre as propostas no topo. A selecção fecha novas propostas e avaliações."><form className="space-y-5" onSubmit={e => { e.preventDefault(); selection.post(`/requisicoes/${requisition.id}/adjudicacao`); }}><FormErrors errors={selection.errors} /><div className="space-y-2"><Label htmlFor="award-quote">Proposta</Label><select id="award-quote" className={controlClass} required value={selection.data.quotation_id} onChange={e => selection.setData('quotation_id', e.target.value)}><option value="">Seleccione uma proposta no topo</option>{comparison.rows.filter(q => comparison.top_ids.includes(q.id)).map(q => <option key={q.id} value={q.id}>{q.supplier.name} · {money(q.total_minor)} · {q.score?.toFixed(2)} / 10</option>)}</select></div><div className="space-y-2"><Label htmlFor="award-justification">Justificação da adjudicação</Label><textarea id="award-justification" className={`${controlClass} h-24 py-3`} required minLength={10} maxLength={5000} value={selection.data.justification} onChange={e => selection.setData('justification', e.target.value)} /></div><div className="grid gap-4 sm:grid-cols-2"><div className="space-y-2"><Label htmlFor="award-date">Data acordada de entrega</Label><Input id="award-date" type="date" required value={selection.data.delivery_date} onChange={e => selection.setData('delivery_date', e.target.value)} /></div><div className="space-y-2"><Label htmlFor="award-location">Local de entrega</Label><Input id="award-location" required maxLength={500} value={selection.data.delivery_location} onChange={e => selection.setData('delivery_location', e.target.value)} /></div></div><p className="text-sm text-muted-foreground">Será aberto um novo ciclo de aprovações pelo valor adjudicado. As decisões da estimativa ficam preservadas e não são reutilizadas. Solicitante e autor da adjudicação não aprovam este ciclo.</p><Button disabled={selection.processing || !comparison.ready}>Registar adjudicação para aprovação</Button></form></Panel>}
{award && <Panel title={`Adjudicação #${award.id}`} description={`Estado: ${award.status === 'pending' ? 'Em aprovação' : award.status === 'approved' ? 'Aprovada para emissão' : 'Rejeitada'}`}><p className="text-sm whitespace-pre-wrap">{award.justification}</p><dl className="mt-5 grid gap-4 text-sm sm:grid-cols-3"><div><dt className="text-muted-foreground">Total final com IVA</dt><dd className="mt-1 font-semibold">{money(award.total_minor)}</dd></div><div><dt className="text-muted-foreground">Base dos escalões guardados</dt><dd className="mt-1">{award.approval_snapshot.approval_includes_tax ? 'Com IVA' : 'Sem IVA'}{award.approval_snapshot.rules_provisional ? ' · Provisórios' : ''}</dd></div><div><dt className="text-muted-foreground">Entrega</dt><dd className="mt-1">{date(award.delivery_date)} · {award.delivery_location}</dd></div></dl><ol className="mt-6 space-y-3 border-t pt-5">{award.approvals.map(step => <li key={step.id} className="text-sm"><strong>{step.position}. {roleLabels[step.role]}</strong> · {step.decision === 'pending' ? 'Aguarda decisão' : step.decision === 'approved' ? 'Aprovada' : 'Rejeitada'}{step.approver_id ? ` · Utilizador #${step.approver_id}` : ''}{step.comment && <p className="mt-1 text-muted-foreground">{step.comment}</p>}</li>)}</ol></Panel>}
{canDecide && award && <Panel title="Decidir sobre o valor final"><form className="space-y-4" onSubmit={e => { e.preventDefault(); decision.post(`/adjudicacoes/${award.id}/decidir`); }}><FormErrors errors={decision.errors} /><Label htmlFor="award-decision">Decisão</Label><select id="award-decision" className={controlClass} value={decision.data.decision} onChange={e => decision.setData('decision', e.target.value)}><option value="approved">Aprovar etapa</option><option value="rejected">Rejeitar adjudicação</option></select><Label htmlFor="award-comment">Fundamento (obrigatório se rejeitar)</Label><textarea id="award-comment" className={`${controlClass} h-24 py-3`} required={decision.data.decision === 'rejected'} maxLength={2000} value={decision.data.comment} onChange={e => decision.setData('comment', e.target.value)} /><Button disabled={decision.processing}>Registar decisão</Button></form></Panel>}
{order ? <Button asChild><Link href={`/ordens/${order.id}`}>Abrir ordem {order.number}</Link></Button> : canIssue && award && <Panel title="Emitir ordem para revisão" description="O servidor revalida a matriz, os conflitos e as aprovações. A ordem emitida mantém um retrato dos dados e não pode ser reescrita."><FormErrors errors={issue.errors} /><Button className="mt-4" disabled={issue.processing} onClick={() => issue.post(`/adjudicacoes/${award.id}/emitir`)}>Emitir documento de desenvolvimento</Button></Panel>}
</div></>;
}
Award.layout = { breadcrumbs: [{ title: 'Ordens de compra', href: '/ordens' }, { title: 'Adjudicação', href: '#' }] };
+10
View File
@@ -0,0 +1,10 @@
import { Head, Link } from '@inertiajs/react';
import { PageTitle, Panel } from '@/components/procurement';
import { money, date } from '@/types/procurement';
import type { PurchaseAward, PurchaseOrder } from '@/types/purchase';
type Page<T> = { data: T[]; prev_page_url: string | null; next_page_url: string | null };
export default function Orders({ orders, awards }: { orders: Page<PurchaseOrder>; awards: Page<PurchaseAward> }) {
return <><Head title="Ordens de compra" /><div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8"><PageTitle eyebrow="Compras" title="Adjudicações e ordens" description="Acompanhe as aprovações dos valores finais e os documentos emitidos para revisão." /><Panel title="Ordens emitidas"><div className="overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b text-muted-foreground"><tr><th className="pb-3 pr-4">Ordem</th><th className="pr-4">Fornecedor</th><th className="pr-4">Emissão</th><th>Total com IVA</th></tr></thead><tbody>{orders.data.map(order => <tr key={order.id} className="border-b last:border-0"><td className="py-4 pr-4"><Link href={`/ordens/${order.id}`} className="font-medium text-teal-700 dark:text-teal-400">{order.number}</Link><p className="mt-1 text-xs text-muted-foreground">{order.snapshot.requisition.number}</p></td><td className="pr-4">{order.snapshot.supplier.name}</td><td className="pr-4">{date(order.issued_at)}</td><td>{money(order.total_minor)}</td></tr>)}</tbody></table></div>{orders.data.length === 0 && <p className="py-6 text-sm text-muted-foreground">Ainda não foram emitidas ordens.</p>}<div className="mt-4 flex gap-4 text-sm">{orders.prev_page_url && <Link href={orders.prev_page_url}>Anterior</Link>}{orders.next_page_url && <Link href={orders.next_page_url}>Seguinte</Link>}</div></Panel><Panel title="Adjudicações"><div className="divide-y">{awards.data.map(award => <Link href={`/requisicoes/${award.requisition_id}/adjudicacao`} key={award.id} className="flex items-center justify-between gap-4 py-4 text-sm"><div><strong>Adjudicação #{award.id}</strong><p className="mt-1 text-muted-foreground">{award.status === 'pending' ? 'Em aprovação' : award.status === 'approved' ? 'Aprovada para emissão' : 'Rejeitada'}</p></div><span>{money(award.total_minor)}</span></Link>)}{awards.data.length === 0 && <p className="py-6 text-sm text-muted-foreground">Inicie a adjudicação a partir da comparação de cotações.</p>}</div><div className="mt-4 flex gap-4 text-sm">{awards.prev_page_url && <Link href={awards.prev_page_url}>Anterior</Link>}{awards.next_page_url && <Link href={awards.next_page_url}>Seguinte</Link>}</div></Panel></div></>;
}
Orders.layout = { breadcrumbs: [{ title: 'Ordens de compra', href: '/ordens' }] };
+19
View File
@@ -0,0 +1,19 @@
import { Head, Link } from '@inertiajs/react';
import { Printer } from 'lucide-react';
import { PageTitle, Panel } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { date, money, roleLabels } from '@/types/procurement';
import type { PurchaseOrder } from '@/types/purchase';
type History = { id: number; action: string; user_id: number; created_at: string };
const actions: Record<string, string> = { 'award.created': 'Adjudicação registada', 'award.approved': 'Etapa do valor final aprovada', 'award.rejected': 'Adjudicação rejeitada', 'purchase_order.issued': 'Ordem emitida para revisão' };
export default function OrderShow({ order, history }: { order: PurchaseOrder; history: History[] }) {
const snapshot = order.snapshot;
return <><Head title={order.number} /><div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8"><PageTitle eyebrow={snapshot.company.name} title={order.number} description={`${snapshot.requisition.number} · Emitida em ${date(order.issued_at)} por ${snapshot.issuer.name}`} action={<Button asChild><a href={`/ordens/${order.id}/imprimir`} target="_blank" rel="noopener noreferrer"><Printer className="size-4" />Imprimir / guardar PDF</a></Button>} /><div className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100"><strong>Documento de desenvolvimento. Não autoriza compras reais.</strong> Orçamento não verificado. Os dados apresentados foram congelados na emissão.{snapshot.approval_rules.rules_provisional && ' Regras de aprovação provisórias.'}</div>
<div className="grid gap-6 md:grid-cols-2"><Panel title="Fornecedor"><dl className="space-y-3 text-sm"><div><dt className="text-muted-foreground">Nome e identificação fiscal</dt><dd>{snapshot.supplier.name} · NUIT {snapshot.supplier.nuit}</dd></div><div><dt className="text-muted-foreground">Contacto</dt><dd>{snapshot.supplier.contact} · {snapshot.supplier.phone}<br />{snapshot.supplier.email}</dd></div><div><dt className="text-muted-foreground">Morada</dt><dd className="whitespace-pre-wrap">{snapshot.supplier.address}</dd></div></dl></Panel><Panel title="Condições acordadas"><dl className="space-y-3 text-sm"><div><dt className="text-muted-foreground">Entrega</dt><dd>{date(snapshot.award.delivery_date)} · {snapshot.award.delivery_location}</dd></div><div><dt className="text-muted-foreground">Pagamento</dt><dd>{snapshot.quotation.payment_terms}</dd></div><div><dt className="text-muted-foreground">Departamento e centro de custo</dt><dd>{snapshot.department.name} · {snapshot.cost_center.code} · {snapshot.cost_center.name}</dd></div><div><dt className="text-muted-foreground">Proposta</dt><dd>{snapshot.quotation.reference} · {order.currency}</dd></div></dl></Panel></div>
<Panel title="Artigos e valores congelados"><div className="overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b text-muted-foreground"><tr><th className="pb-3 pr-4">Descrição</th><th className="pr-4">Quantidade</th><th className="pr-4">Preço sem IVA</th><th className="pr-4">IVA</th><th>Total com IVA</th></tr></thead><tbody>{order.items.map(item => <tr key={item.id} className="border-b"><td className="py-4 pr-4">{item.description}</td><td className="pr-4 whitespace-nowrap">{Number(item.quantity)} {item.unit}</td><td className="pr-4 whitespace-nowrap">{money(item.unit_price_minor)}</td><td className="pr-4">{Number(item.tax_rate)}%</td><td className="whitespace-nowrap">{money(item.total_minor)}</td></tr>)}</tbody></table></div><dl className="mt-5 ml-auto max-w-sm space-y-3 text-sm"><div className="flex justify-between gap-4"><dt>Subtotal sem IVA</dt><dd>{money(order.subtotal_minor)}</dd></div><div className="flex justify-between gap-4"><dt>IVA</dt><dd>{money(order.tax_minor)}</dd></div><div className="flex justify-between gap-4 border-t pt-3 font-semibold"><dt>Total com IVA</dt><dd>{money(order.total_minor)}</dd></div></dl></Panel>
<Panel title="Fundamento e matriz congelada"><p className="text-sm whitespace-pre-wrap">{snapshot.award.justification}</p><div className="mt-5 space-y-4">{snapshot.decision_matrix.map(row => <div key={row.id} className="rounded-lg border p-4 text-sm"><p className="font-semibold">{row.supplier.name} · {row.reference} · {row.score === null ? 'Não elegível / incompleta' : `${row.score.toFixed(2)} / 10`}</p><p className="mt-1 text-muted-foreground">{money(row.total_minor)} com IVA · {row.evaluation_count} avaliações elegíveis</p>{row.evaluations.map(e => <p key={e.evaluator_id} className="mt-2 text-xs">Avaliador #{e.evaluator_id}: qualidade {e.quality}, histórico {e.history}, pagamento {e.payment}. {e.comment}</p>)}</div>)}</div></Panel>
<Panel title="Aprovações do valor final"><ol className="space-y-3">{snapshot.final_approvals.map(step => <li key={step.id} className="text-sm">{roleLabels[step.role]} · Utilizador #{step.approver_id} · Aprovada{step.comment && <p className="mt-1 text-muted-foreground">{step.comment}</p>}</li>)}</ol><p className="mt-4 text-xs text-muted-foreground">Base: {snapshot.approval_rules.approval_includes_tax ? 'com' : 'sem'} IVA. As aprovações da estimativa foram preservadas separadamente.</p></Panel><Panel title="Histórico do processo"><ol className="space-y-3">{history.map(event => <li key={event.id} className="text-sm">{actions[event.action]} · {date(event.created_at)} · Utilizador #{event.user_id}</li>)}</ol></Panel><Link href="/ordens" className="text-sm text-teal-700 dark:text-teal-400">Voltar às ordens</Link>
</div></>;
}
OrderShow.layout = { breadcrumbs: [{ title: 'Ordens de compra', href: '/ordens' }, { title: 'Documento emitido', href: '#' }] };
+83
View File
@@ -0,0 +1,83 @@
import { Head, Link } from '@inertiajs/react';
import { PageTitle, Panel } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { money } from '@/types/procurement';
type Process = {
id: number;
number: string;
justification: string;
total_minor: number;
department: { name: string };
};
export default function Quotations({
requisitions,
}: {
requisitions: {
data: Process[];
prev_page_url: string | null;
next_page_url: string | null;
};
}) {
return (
<>
<Head title="Cotações" />
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow="Consulta ao mercado"
title="Cotações e avaliação"
description="Requisições aprovadas disponíveis para recolha e comparação de propostas."
/>
<Panel title="Processos de compra">
<div className="divide-y">
{requisitions.data.map((r) => (
<div
className="flex flex-wrap items-center justify-between gap-4 py-5"
key={r.id}
>
<div>
<strong>
{r.number} · {r.department.name}
</strong>
<p className="mt-1 text-sm text-muted-foreground">
{r.justification}
</p>
<p className="mt-2 text-sm">
Estimativa com IVA:{' '}
{money(r.total_minor)}
</p>
</div>
<Button asChild variant="outline">
<Link
href={`/requisicoes/${r.id}/cotacoes`}
>
Comparar propostas
</Link>
</Button>
</div>
))}
</div>
{!requisitions.data.length && (
<p className="py-8 text-center text-muted-foreground">
As requisições surgem aqui depois de todas as
aprovações.
</p>
)}
<div className="mt-4 flex gap-4">
{requisitions.prev_page_url && (
<Link href={requisitions.prev_page_url}>
Anterior
</Link>
)}
{requisitions.next_page_url && (
<Link href={requisitions.next_page_url}>
Seguinte
</Link>
)}
</div>
</Panel>
</div>
</>
);
}
Quotations.layout = { breadcrumbs: [{ title: 'Cotações', href: '/cotacoes' }] };
+533
View File
@@ -0,0 +1,533 @@
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import { useState } from 'react';
import {
FormErrors,
PageTitle,
Panel,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { money } from '@/types/procurement';
type Item = {
id: number;
description: string;
quantity: string;
tax_rate: string;
unit_price_minor: number;
total_minor: number;
};
type Evaluation = {
id: number;
evaluator_id: number;
quality: string;
history: string;
payment: string;
comment: string;
};
type Quote = {
id: number;
reference: string;
supplier: { name: string };
subtotal_minor: number;
tax_minor: number;
total_minor: number;
delivery_days: number;
valid_until: string;
payment_terms: string;
evaluations: Evaluation[];
eligible: boolean;
conflict_blocked: boolean;
score: number | null;
items: Item[];
};
export default function Comparison({
requisition,
quotations,
suppliers,
canCreate,
canEvaluate,
ready,
}: {
requisition: {
id: number;
number: string;
justification: string;
items: Item[];
};
quotations: Quote[];
suppliers: { id: number; name: string }[];
canCreate: boolean;
canEvaluate: boolean;
ready: boolean;
}) {
const { auth } = usePage().props;
const [adding, setAdding] = useState(false);
const [selected, setSelected] = useState<number | null>(null);
const form = useForm({
supplier_id: '',
reference: '',
delivery_days: '1',
valid_until: '',
payment_terms: '',
no_conflict: false,
items: requisition.items.map((i) => ({
requisition_item_id: i.id,
unit_price: '',
})),
});
const evaluation = useForm({
quality: '5',
history: '5',
payment: '5',
comment: '',
no_conflict: false,
});
return (
<>
<Head title={`Cotações · ${requisition.number}`} />
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow={requisition.number}
title="Comparar propostas"
description={requisition.justification}
action={
canCreate ? (
<Button onClick={() => setAdding(!adding)}>
{adding
? 'Fechar formulário'
: 'Registar cotação'}
</Button>
) : undefined
}
/>
<Link className="text-sm font-medium text-teal-700 dark:text-teal-400" href={`/requisicoes/${requisition.id}/adjudicacao`}>Adjudicação, aprovações do valor final e ordem de compra</Link>
<Link
className="text-sm text-teal-700"
href={`/requisicoes/${requisition.id}`}
>
Consultar requisição e aprovações
</Link>
<Link className="text-sm text-teal-700" href="/conflitos">
Registar ou consultar a minha declaração de conflito
</Link>
<div
className={`rounded-xl border p-5 text-sm ${ready ? 'border-teal-200 bg-teal-50 text-teal-900' : 'border-amber-200 bg-amber-50 text-amber-900'}`}
>
<strong>
{ready
? 'Comparação completa para revisão.'
: 'Comparação ainda incompleta.'}
</strong>{' '}
São necessárias três propostas elegíveis de fornecedores
distintos e duas avaliações independentes por proposta. Esta
etapa prepara a adjudicação e a emissão numa fase própria.
</div>
{adding && (
<Panel
title="Nova proposta"
description="MZN · Quantidades e IVA herdados da requisição. Os preços são calculados no servidor."
>
<form
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
form.post(
`/requisicoes/${requisition.id}/cotacoes`,
{
onSuccess: () => {
form.reset();
setAdding(false);
},
},
);
}}
>
<FormErrors errors={form.errors} />
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="supplier">
Fornecedor activo
</Label>
<select
id="supplier"
className={controlClass}
required
value={form.data.supplier_id}
onChange={(e) =>
form.setData(
'supplier_id',
e.target.value,
)
}
>
<option value="">Seleccione</option>
{suppliers.map((s) => (
<option value={s.id} key={s.id}>
{s.name}
</option>
))}
</select>
</div>
{(
[
[
'reference',
'Referência do fornecedor',
'text',
],
[
'delivery_days',
'Prazo de entrega (dias)',
'number',
],
['valid_until', 'Válida até', 'date'],
[
'payment_terms',
'Condições de pagamento',
'text',
],
] as const
).map(([key, label, type]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
type={type}
min={
type === 'number'
? 1
: undefined
}
required
value={form.data[key]}
onChange={(e) =>
form.setData(
key,
e.target.value,
)
}
/>
</div>
))}
</div>
<div className="space-y-4">
{requisition.items.map((i, index) => (
<div
className="grid items-center gap-3 rounded-lg border p-4 md:grid-cols-2"
key={i.id}
>
<div>
<strong className="text-sm">
{i.description}
</strong>
<p className="text-xs text-muted-foreground">
{i.quantity} unidades · IVA{' '}
{i.tax_rate}%
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`price-${i.id}`}>
Preço unitário sem IVA (MZN)
</Label>
<Input
id={`price-${i.id}`}
type="number"
min="0.01"
step="0.01"
required
value={
form.data.items[index]
.unit_price
}
onChange={(e) =>
form.setData(
'items',
form.data.items.map(
(line, k) =>
k === index
? {
...line,
unit_price:
e
.target
.value,
}
: line,
),
)
}
/>
</div>
</div>
))}
</div>
<label className="flex items-start gap-3 text-sm">
<input
type="checkbox"
required
checked={form.data.no_conflict}
onChange={(e) =>
form.setData(
'no_conflict',
e.target.checked,
)
}
/>
Declaro que não tenho conflito de interesses com
este fornecedor.
</label>
<Button disabled={form.processing}>
Guardar proposta
</Button>
</form>
</Panel>
)}
<Panel
title="Matriz de comparação"
description="Pesos provisórios: preço 40%, qualidade 20%, prazo 15%, histórico 15%, pagamento 10%. Preço e prazo calculados automaticamente entre propostas elegíveis."
>
<div className="overflow-auto">
<table className="w-full text-left text-sm">
<thead className="border-b text-xs text-muted-foreground">
<tr>
{[
'Fornecedor / referência',
'Sem IVA',
'IVA',
'Total',
'Entrega',
'Avaliações',
'Pontuação',
'',
].map((h, i) => (
<th key={i} className="pr-4 pb-3">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{quotations.map((q) => (
<tr className="border-b" key={q.id}>
<td className="py-4 pr-4">
<strong>{q.supplier.name}</strong>
<p className="text-xs text-muted-foreground">
{q.reference} · válida até{' '}
{q.valid_until.slice(0, 10)}
</p>
{!q.eligible && (
<p className="text-xs text-red-700">
Proposta não elegível:
validade, fornecedor ou
conflito de intervenientes.
</p>
)}
{q.conflict_blocked && (
<p className="text-xs text-red-700">
Participação impedida por
conflito de interesses.
</p>
)}
</td>
<td className="pr-4 whitespace-nowrap">
{money(q.subtotal_minor)}
</td>
<td className="pr-4 whitespace-nowrap">
{money(q.tax_minor)}
</td>
<td className="pr-4 whitespace-nowrap">
{money(q.total_minor)}
</td>
<td className="pr-4">
{q.delivery_days} dias
</td>
<td className="pr-4">
{q.evaluation_count} / 2
</td>
<td className="pr-4">
{q.score === null
? 'Pendente'
: `${q.score.toFixed(2)} / 10`}
</td>
<td>
<Button
variant="outline"
onClick={() => {
setSelected(q.id);
evaluation.reset();
evaluation.clearErrors();
}}
>
Detalhe
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{!quotations.length && (
<p className="py-8 text-center text-muted-foreground">
Ainda não foram registadas propostas.
</p>
)}
</Panel>
{quotations
.filter((q) => q.id === selected)
.map((q) => (
<Panel
key={q.id}
title={`${q.supplier.name} · ${q.reference}`}
description={`Pagamento: ${q.payment_terms}`}
>
<div className="space-y-3">
{q.items.map((i) => (
<p className="text-sm" key={i.id}>
{i.description} · {i.quantity} ×{' '}
{money(i.unit_price_minor)} · total com
IVA {money(i.total_minor)}
</p>
))}
</div>
<h3 className="mt-6 mb-3 font-semibold">
Avaliações registadas
</h3>
{q.evaluations.map((ev) => (
<div
key={ev.id}
className="mb-3 rounded-lg border p-4 text-sm"
>
<strong>
Avaliador #{ev.evaluator_id}
</strong>
<p>
Qualidade {ev.quality} · histórico{' '}
{ev.history} · pagamento {ev.payment}
</p>
<p className="mt-2 whitespace-pre-wrap text-muted-foreground">
{ev.comment}
</p>
</div>
))}
{canEvaluate &&
q.eligible &&
!q.conflict_blocked &&
!q.evaluations.some(
(ev) => ev.evaluator_id === auth.user.id,
) && (
<form
className="mt-5 space-y-4 border-t pt-5"
onSubmit={(e) => {
e.preventDefault();
evaluation.post(
`/cotacoes/${q.id}/avaliar`,
{
onSuccess: () =>
evaluation.reset(),
},
);
}}
>
<FormErrors
errors={evaluation.errors}
/>
<div className="grid gap-4 md:grid-cols-3">
{(
[
[
'quality',
'Qualidade técnica',
],
[
'history',
'Histórico (5 para novo)',
],
[
'payment',
'Condições de pagamento',
],
] as const
).map(([key, label]) => (
<div
className="space-y-2"
key={key}
>
<Label htmlFor={key}>
{label} · 0 a 10
</Label>
<Input
id={key}
type="number"
min="0"
max="10"
step="0.01"
required
value={
evaluation.data[key]
}
onChange={(e) =>
evaluation.setData(
key,
e.target.value,
)
}
/>
</div>
))}
</div>
<Label htmlFor="comment">
Fundamento da avaliação
</Label>
<textarea
id="comment"
className={controlClass}
required
minLength={10}
value={evaluation.data.comment}
onChange={(e) =>
evaluation.setData(
'comment',
e.target.value,
)
}
/>
<label className="flex items-start gap-3 text-sm">
<input
type="checkbox"
required
checked={
evaluation.data.no_conflict
}
onChange={(e) =>
evaluation.setData(
'no_conflict',
e.target.checked,
)
}
/>
Declaro não ter conflito de
interesses e confirmo a minha
avaliação independente.
</label>
<Button
disabled={evaluation.processing}
>
Registar avaliação
</Button>
</form>
)}
</Panel>
))}
</div>
</>
);
}
Comparison.layout = {
breadcrumbs: [
{ title: 'Cotações', href: '/cotacoes' },
{ title: 'Comparação', href: '#' },
],
};
+378
View File
@@ -0,0 +1,378 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { Plus, Save, Trash2 } from 'lucide-react';
import {
FormErrors,
PageTitle,
Panel,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { money } from '@/types/procurement';
import type { CostCenter, Requisition } from '@/types/procurement';
type Line = {
key: string;
description: string;
category: string;
purchase_type: string;
quantity: string;
unit: string;
unit_price: string;
tax_rate: string;
};
const emptyLine = (): Line => ({
key: crypto.randomUUID(),
description: '',
category: '',
purchase_type: 'consumable',
quantity: '1',
unit: 'un',
unit_price: '',
tax_rate: '0',
});
const lineFields = [
{ key: 'description', label: 'Descrição', type: 'text', maxLength: 255 },
{ key: 'category', label: 'Categoria', type: 'text', maxLength: 100 },
{
key: 'quantity',
label: 'Quantidade',
type: 'number',
min: '0.001',
max: '1000000',
step: '0.001',
},
{ key: 'unit', label: 'Unidade', type: 'text', maxLength: 30 },
{
key: 'unit_price',
label: 'Preço unitário sem IVA (MZN)',
type: 'number',
min: '0.01',
max: '100000000',
step: '0.01',
},
{
key: 'tax_rate',
label: 'Taxa de IVA (%)',
type: 'number',
min: '0',
max: '100',
step: '0.01',
},
] as const;
export default function RequisitionForm({
costCenters,
requisition,
}: {
costCenters: CostCenter[];
requisition: Requisition | null;
}) {
const form = useForm({
cost_center_id: requisition ? String(requisition.cost_center_id) : '',
priority: requisition?.priority ?? 'normal',
needed_by: requisition?.needed_by ?? '',
justification: requisition?.justification ?? '',
items: requisition?.items.map((i) => ({
key: String(i.id),
description: i.description,
category: i.category,
purchase_type: i.purchase_type,
quantity: String(i.quantity),
unit: i.unit,
unit_price: (i.unit_price_minor / 100).toFixed(2),
tax_rate: String(i.tax_rate),
})) ?? [emptyLine()],
});
const changeLine = (index: number, field: keyof Line, value: string) =>
form.setData(
'items',
form.data.items.map((item, i) =>
i === index ? { ...item, [field]: value } : item,
),
);
const estimate = form.data.items.reduce(
(sum, i) =>
sum +
Math.round(
Math.round(Number(i.quantity) * Number(i.unit_price) * 100) *
(1 + Number(i.tax_rate) / 100),
),
0,
);
return (
<>
<Head
title={requisition ? 'Editar requisição' : 'Nova requisição'}
/>
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow="Pedido de compra"
title={
requisition
? `Editar ${requisition.number}`
: 'Nova requisição'
}
description="Descreva a necessidade e os artigos. Guarde o rascunho e confira os valores antes de submeter."
/>
<form
className="space-y-6"
onSubmit={(e) => {
e.preventDefault();
if (requisition)
form.put(`/requisicoes/${requisition.id}`);
else form.post('/requisicoes');
}}
>
<FormErrors errors={form.errors} />
{costCenters.length === 0 && (
<div
role="alert"
className="rounded-lg border p-4 text-sm"
>
O seu departamento ainda não tem centros de custo.
Peça ao administrador para criar um antes de
registar pedidos.
</div>
)}
<Panel
title="Informações do pedido"
description="O solicitante e o departamento são determinados pela sua conta."
>
<div className="grid gap-5 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="cost-center">
Centro de custo
</Label>
<select
id="cost-center"
required
className={controlClass}
value={form.data.cost_center_id}
onChange={(e) =>
form.setData(
'cost_center_id',
e.target.value,
)
}
>
<option value="">Seleccione</option>
{costCenters.map((c) => (
<option key={c.id} value={c.id}>
{c.code} · {c.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="priority">Prioridade</Label>
<select
id="priority"
className={controlClass}
value={form.data.priority}
onChange={(e) =>
form.setData('priority', e.target.value)
}
>
<option value="normal">Normal</option>
<option value="high">Alta</option>
<option value="urgent">Urgente</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="needed-by">
Entrega pretendida
</Label>
<Input
id="needed-by"
type="date"
required
value={form.data.needed_by}
onChange={(e) =>
form.setData(
'needed_by',
e.target.value,
)
}
/>
</div>
<div className="space-y-2 sm:col-span-3">
<Label htmlFor="justification">
Justificação da compra
</Label>
<textarea
id="justification"
required
minLength={10}
maxLength={5000}
className={`${controlClass} h-24 py-3`}
placeholder="Explique a necessidade e a utilização prevista…"
value={form.data.justification}
onChange={(e) =>
form.setData(
'justification',
e.target.value,
)
}
/>
</div>
</div>
</Panel>
<Panel
title="Artigos e serviços"
description="Preços em MZN. Indique a taxa de IVA aplicável; o valor inicial de 0% deve ser revisto por Finanças."
>
<div className="space-y-5">
{form.data.items.map((line, index) => (
<fieldset
className="rounded-lg border bg-muted/20 p-4"
key={line.key}
>
<legend className="px-2 text-xs font-semibold text-muted-foreground">
LINHA {index + 1}
</legend>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{lineFields.map(
({ key, label, ...attributes }) => (
<div
className="space-y-2"
key={key}
>
<Label
htmlFor={`${key}-${line.key}`}
>
{label}
</Label>
<Input
id={`${key}-${line.key}`}
{...attributes}
required
value={line[key]}
onChange={(e) =>
changeLine(
index,
key,
e.target.value,
)
}
/>
</div>
),
)}
<div className="space-y-2">
<Label htmlFor={`type-${line.key}`}>
Tipo de compra
</Label>
<select
id={`type-${line.key}`}
className={controlClass}
value={line.purchase_type}
onChange={(e) =>
changeLine(
index,
'purchase_type',
e.target.value,
)
}
>
<option value="consumable">
Insumos / matéria-prima
</option>
<option value="service">
Serviço
</option>
<option value="other">
Outro
</option>
</select>
</div>
</div>
<div className="mt-3 flex justify-end">
<Button
type="button"
variant="ghost"
size="sm"
disabled={
form.data.items.length === 1
}
onClick={() =>
form.setData(
'items',
form.data.items.filter(
(_, i) => i !== index,
),
)
}
>
<Trash2 className="size-4" />
Remover linha
</Button>
</div>
</fieldset>
))}
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-4">
<Button
type="button"
variant="outline"
disabled={form.data.items.length >= 50}
onClick={() =>
form.setData('items', [
...form.data.items,
emptyLine(),
])
}
>
<Plus className="size-4" />
Adicionar linha
</Button>
<p className="text-sm">
Total estimado com IVA{' '}
<strong className="ml-3 text-lg">
{money(
Number.isFinite(estimate)
? estimate
: 0,
)}
</strong>
</p>
</div>
<p className="mt-4 text-xs text-muted-foreground">
O servidor confirma os cálculos ao guardar.
Equipamentos e peças terão formulários próprios numa
próxima etapa.
</p>
</Panel>
<div className="flex justify-end gap-3">
<Button variant="outline" asChild>
<Link
href={
requisition
? `/requisicoes/${requisition.id}`
: '/requisicoes'
}
>
Cancelar
</Link>
</Button>
<Button
disabled={
form.processing || costCenters.length === 0
}
>
<Save className="size-4" />
Guardar rascunho
</Button>
</div>
</form>
</div>
</>
);
}
RequisitionForm.layout = {
breadcrumbs: [
{ title: 'Requisições', href: '/requisicoes' },
{ title: 'Formulário', href: '#' },
],
};
+117
View File
@@ -0,0 +1,117 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { Plus, Search } from 'lucide-react';
import {
PageTitle,
Panel,
RequisitionTable,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { statusLabels } from '@/types/procurement';
import type { Requisition } from '@/types/procurement';
type Props = {
requisitions: {
data: Requisition[];
current_page: number;
last_page: number;
prev_page_url: string | null;
next_page_url: string | null;
total: number;
};
filters: { search?: string; status?: string };
};
export default function Index({ requisitions, filters }: Props) {
const form = useForm({
search: filters.search ?? '',
status: filters.status ?? '',
});
return (
<>
<Head title="Requisições" />
<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."
action={
<Button asChild>
<Link href="/requisicoes/criar">
<Plus className="size-4" />
Nova requisição
</Link>
</Button>
}
/>
<Panel title="Pedidos de compra">
<form
className="mb-6 flex flex-wrap gap-3"
onSubmit={(e) => {
e.preventDefault();
form.get('/requisicoes', { preserveState: true });
}}
>
<Input
className="max-w-xs"
aria-label="Pesquisar pela referência"
placeholder="Pesquisar referência…"
value={form.data.search}
onChange={(e) =>
form.setData('search', e.target.value)
}
/>
<select
className={`${controlClass} max-w-48`}
aria-label="Filtrar por estado"
value={form.data.status}
onChange={(e) =>
form.setData('status', e.target.value)
}
>
<option value="">Todos os estados</option>
{Object.entries(statusLabels).map(
([key, label]) => (
<option key={key} value={key}>
{label}
</option>
),
)}
</select>
<Button variant="outline" disabled={form.processing}>
<Search className="size-4" />
Filtrar
</Button>
</form>
<RequisitionTable rows={requisitions.data} />
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t pt-4 text-sm text-muted-foreground">
<p>
{requisitions.total} pedidos · Página{' '}
{requisitions.current_page} de{' '}
{requisitions.last_page}
</p>
<div className="flex gap-2">
{requisitions.prev_page_url && (
<Button asChild variant="outline" size="sm">
<Link href={requisitions.prev_page_url}>
Anterior
</Link>
</Button>
)}
{requisitions.next_page_url && (
<Button asChild variant="outline" size="sm">
<Link href={requisitions.next_page_url}>
Seguinte
</Link>
</Button>
)}
</div>
</div>
</Panel>
</div>
</>
);
}
Index.layout = {
breadcrumbs: [{ title: 'Requisições', href: '/requisicoes' }],
};
+368
View File
@@ -0,0 +1,368 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { Check, Circle, Pencil, Send } from 'lucide-react';
import {
FormErrors,
PageTitle,
Panel,
Status,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { date, money, priorityLabels, roleLabels } from '@/types/procurement';
import type { Requisition } from '@/types/procurement';
type Audit = {
id: number;
action: string;
user_id: number;
created_at: string;
};
const actions: Record<string, string> = {
'requisition.created': 'Rascunho criado',
'requisition.updated': 'Rascunho actualizado',
'requisition.submitted': 'Pedido submetido',
'approval.approved': 'Etapa aprovada',
'approval.rejected': 'Pedido rejeitado',
};
export default function Show({
requisition: r,
canEdit,
canDecide,
conflictBlocked,
audit,
}: {
requisition: Requisition;
canEdit: boolean;
canDecide: boolean;
conflictBlocked: boolean;
audit: Audit[];
}) {
const submission = useForm({});
const decision = useForm<{ decision: string; comment: string }>({
decision: 'approved',
comment: '',
});
return (
<>
<Head title={r.number} />
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow="Requisição de compra"
title={r.number}
description={`${r.requester?.name} · ${r.department?.name} · ${date(r.created_at)}`}
action={<Status value={r.status} />}
/>
<FormErrors errors={submission.errors} />
{conflictBlocked && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
Existe um conflito de interesses activo que impede a
participação neste processo. As operações são
verificadas pelo servidor.{' '}
<Link href="/conflitos" className="underline">
Consultar as declarações disponíveis para o meu
perfil
</Link>
.
</div>
)}
{canEdit && (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border bg-muted/30 p-5">
<p className="text-sm text-muted-foreground">
Confira o pedido antes de o enviar para aprovação.
</p>
<div className="flex gap-2">
<Button asChild variant="outline">
<Link href={`/requisicoes/${r.id}/editar`}>
<Pencil className="size-4" />
Editar
</Link>
</Button>
<Button
disabled={submission.processing}
onClick={() =>
submission.post(
`/requisicoes/${r.id}/submeter`,
)
}
>
<Send className="size-4" />
Submeter para aprovação
</Button>
</div>
</div>
)}
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<Panel title="Detalhes do pedido">
<dl className="grid gap-5 text-sm sm:grid-cols-3">
<div>
<dt className="text-muted-foreground">
Centro de custo
</dt>
<dd className="mt-1 font-medium">
{r.cost_center?.code} ·{' '}
{r.cost_center?.name}
</dd>
</div>
<div>
<dt className="text-muted-foreground">
Prioridade
</dt>
<dd className="mt-1 font-medium">
{priorityLabels[r.priority]}
</dd>
</div>
<div>
<dt className="text-muted-foreground">
Entrega pretendida
</dt>
<dd className="mt-1 font-medium">
{date(r.needed_by)}
</dd>
</div>
<div className="sm:col-span-3">
<dt className="text-muted-foreground">
Justificação
</dt>
<dd className="mt-2 whitespace-pre-wrap">
{r.justification}
</dd>
</div>
</dl>
</Panel>
<Panel
title="Artigos e valores"
description="Moeda: MZN · Montantes estimados"
>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b text-xs text-muted-foreground">
<tr>
<th className="pb-3">Artigo</th>
<th className="pr-3">Qtd.</th>
<th className="pr-3">
Preço s/ IVA
</th>
<th className="pr-3">IVA</th>
<th className="text-right">
Total c/ IVA
</th>
</tr>
</thead>
<tbody>
{r.items.map((i) => (
<tr key={i.id} className="border-b">
<td className="py-4 pr-4">
<p className="font-medium">
{i.description}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{i.category}
</p>
</td>
<td className="pr-3 whitespace-nowrap">
{Number(i.quantity)}{' '}
{i.unit}
</td>
<td className="pr-3 whitespace-nowrap">
{money(i.unit_price_minor)}
</td>
<td className="pr-3">
{Number(i.tax_rate)}%
</td>
<td className="text-right whitespace-nowrap">
{money(i.total_minor)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<dl className="mt-5 ml-auto max-w-xs space-y-3 text-sm">
<div className="flex justify-between">
<dt>Subtotal sem IVA</dt>
<dd>{money(r.subtotal_minor)}</dd>
</div>
<div className="flex justify-between">
<dt>IVA</dt>
<dd>{money(r.tax_minor)}</dd>
</div>
<div className="flex justify-between border-t pt-3 text-base font-semibold">
<dt>Total com IVA</dt>
<dd>{money(r.total_minor)}</dd>
</div>
</dl>
</Panel>
{canDecide && (
<Panel
title="Registar decisão"
description="A decisão fica associada à sua conta e ao histórico deste pedido."
>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
decision.post(
`/requisicoes/${r.id}/decidir`,
{
onSuccess: () =>
decision.reset(),
},
);
}}
>
<FormErrors errors={decision.errors} />
<div className="space-y-2">
<Label htmlFor="decision">
Decisão
</Label>
<select
id="decision"
className={controlClass}
value={decision.data.decision}
onChange={(e) =>
decision.setData(
'decision',
e.target.value,
)
}
>
<option value="approved">
Aprovar etapa
</option>
<option value="rejected">
Rejeitar pedido
</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="comment">
Comentário{' '}
{decision.data.decision ===
'rejected'
? '(obrigatório)'
: '(opcional)'}
</Label>
<textarea
id="comment"
className={`${controlClass} h-24 py-3`}
required={
decision.data.decision ===
'rejected'
}
maxLength={2000}
value={decision.data.comment}
onChange={(e) =>
decision.setData(
'comment',
e.target.value,
)
}
/>
</div>
<Button
disabled={decision.processing}
variant={
decision.data.decision ===
'rejected'
? 'destructive'
: 'default'
}
>
Confirmar decisão
</Button>
</form>
</Panel>
)}
</div>
<div className="space-y-6">
<Panel title="Percurso de aprovação">
{r.steps.length === 0 ? (
<p className="text-sm text-muted-foreground">
As etapas serão calculadas pelo servidor
quando submeter o pedido.
</p>
) : (
<ol className="space-y-5">
{r.steps.map((s) => (
<li className="flex gap-3" key={s.id}>
<span
className={`flex size-8 shrink-0 items-center justify-center rounded-full ${s.decision === 'approved' ? 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-200' : 'bg-muted text-muted-foreground'}`}
>
{s.decision === 'approved' ? (
<Check className="size-4" />
) : (
<Circle className="size-3" />
)}
</span>
<div>
<p className="text-sm font-medium">
{roleLabels[s.role]}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{s.decision === 'pending'
? r.status ===
'rejected'
? 'Não realizada'
: 'Aguarda decisão'
: s.decision ===
'approved'
? 'Aprovada'
: 'Rejeitada'}
{s.approver_id
? ` · Utilizador #${s.approver_id}`
: ''}
</p>
{s.comment && (
<p className="mt-2 text-sm whitespace-pre-wrap">
{s.comment}
</p>
)}
</div>
</li>
))}
</ol>
)}
{r.approval_snapshot?.rules_provisional && (
<p className="mt-5 border-t pt-4 text-xs text-amber-700 dark:text-amber-400">
Regras provisórias guardadas na submissão.
Base:{' '}
{r.approval_snapshot.approval_includes_tax
? 'com'
: 'sem'}{' '}
IVA.
</p>
)}
</Panel>
<Panel title="Histórico">
<ol className="space-y-4">
{audit.map((a) => (
<li
key={a.id}
className="border-l-2 border-teal-600/30 pl-3"
>
<p className="text-sm font-medium">
{actions[a.action] ?? a.action}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{date(a.created_at)} · Utilizador #
{a.user_id}
</p>
</li>
))}
</ol>
</Panel>
</div>
</div>
</div>
</>
);
}
Show.layout = {
breadcrumbs: [
{ title: 'Requisições', href: '/requisicoes' },
{ title: 'Detalhe', href: '#' },
],
};
+139
View File
@@ -0,0 +1,139 @@
import { Head, Link, useForm } from '@inertiajs/react';
import {
FormErrors,
PageTitle,
Panel,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { supplierFields } from '@/types/supplier';
import type { Supplier } from '@/types/supplier';
export default function EditSupplier({ supplier }: { supplier: Supplier }) {
const form = useForm({
name: supplier.name,
nuit: supplier.nuit,
contact: supplier.contact,
email: supplier.email,
phone: supplier.phone,
address: supplier.address,
category: supplier.category,
beneficiaries: supplier.beneficiaries,
document_references: supplier.document_references,
diligence_notes: supplier.diligence_notes,
next_review: supplier.next_review.slice(0, 10),
blocked: supplier.blocked,
version: supplier.version,
});
return (
<>
<Head title={`Editar ${supplier.name}`} />
<div className="mx-auto flex w-full max-w-4xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow="Fornecedores"
title="Editar ficha do fornecedor"
description={supplier.name}
/>
<Panel title="Dados e renovação da diligência">
<p className="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
Alterações à ficha retiram a validação actual. Finanças
terá de rever o fornecedor antes de este voltar a ser
elegível para novas cotações.
</p>
<form
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
form.put(`/fornecedores/${supplier.id}`);
}}
>
<FormErrors errors={form.errors} />
<div className="grid gap-4 md:grid-cols-2">
{Object.entries(supplierFields).map(
([key, label]) => {
const name =
key as keyof typeof supplierFields;
const multiline = [
'beneficiaries',
'document_references',
'diligence_notes',
'address',
].includes(key);
return (
<div className="space-y-2" key={key}>
<Label htmlFor={`supplier-${key}`}>
{label}
</Label>
{multiline ? (
<textarea
id={`supplier-${key}`}
required
className={`${controlClass} h-24 py-2`}
value={form.data[name]}
onChange={(e) =>
form.setData(
name,
e.target.value,
)
}
/>
) : (
<Input
id={`supplier-${key}`}
required
type={
key === 'email'
? 'email'
: key ===
'next_review'
? 'date'
: 'text'
}
value={form.data[name]}
onChange={(e) =>
form.setData(
name,
e.target.value,
)
}
/>
)}
</div>
);
},
)}
</div>
<label className="flex items-center gap-3 text-sm">
<input
type="checkbox"
checked={form.data.blocked}
onChange={(e) =>
form.setData('blocked', e.target.checked)
}
/>
Fornecedor consta da lista de impedidos
</label>
<div className="flex justify-end gap-3">
<Button asChild variant="outline">
<Link href={`/fornecedores/${supplier.id}`}>
Cancelar
</Link>
</Button>
<Button disabled={form.processing}>
Guardar alterações
</Button>
</div>
</form>
</Panel>
</div>
</>
);
}
EditSupplier.layout = {
breadcrumbs: [
{ title: 'Fornecedores', href: '/fornecedores' },
{ title: 'Editar ficha', href: '#' },
],
};
+348
View File
@@ -0,0 +1,348 @@
import { Head, Link, router, useForm } from '@inertiajs/react';
import { useState } from 'react';
import {
FormErrors,
PageTitle,
Panel,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
type Supplier = {
id: number;
name: string;
nuit: string;
contact: string;
email: string;
phone: string;
address: string;
category: string;
beneficiaries: string;
document_references: string;
diligence_notes: string;
status: string;
blocked: boolean;
next_review: string;
review_comment: string | null;
version: number;
};
const labels: Record<string, string> = {
pending: 'Pendente de validação',
active: 'Activo',
suspended: 'Suspenso',
};
const fields = {
name: 'Nome legal',
nuit: 'NUIT (9 dígitos)',
contact: 'Pessoa de contacto',
email: 'Email',
phone: 'Telefone',
address: 'Morada',
category: 'Categoria de fornecimento',
beneficiaries: 'Beneficiários efectivos',
document_references: 'Referências dos documentos verificados',
diligence_notes: 'Resultado da diligência devida',
next_review: 'Próxima revisão',
};
export default function Suppliers({
suppliers,
filters,
canCreate,
canReview,
}: {
suppliers: {
data: Supplier[];
prev_page_url: string | null;
next_page_url: string | null;
};
filters: { search?: string };
canCreate: boolean;
canReview: boolean;
}) {
const form = useForm({
name: '',
nuit: '',
contact: '',
email: '',
phone: '',
address: '',
category: '',
beneficiaries: '',
document_references: '',
diligence_notes: '',
next_review: '',
blocked: false,
});
const review = useForm({ status: 'active', comment: '', version: 0 });
const [creating, setCreating] = useState(false);
const [selected, setSelected] = useState<Supplier | null>(null);
const [search, setSearch] = useState(filters.search ?? '');
return (
<>
<Head title="Fornecedores" />
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow="Consulta ao mercado"
title="Fornecedores"
description="Cadastro, diligência devida e validação independente por Finanças."
action={
canCreate ? (
<Button onClick={() => setCreating(!creating)}>
{creating
? 'Fechar formulário'
: 'Novo fornecedor'}
</Button>
) : undefined
}
/>
{creating && (
<Panel title="Registar fornecedor">
<form
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
form.post('/fornecedores', {
onSuccess: () => {
form.reset();
setCreating(false);
},
});
}}
>
<FormErrors errors={form.errors} />
<div className="grid gap-4 md:grid-cols-2">
{Object.entries(fields).map(([key, label]) => {
const name = key as keyof typeof fields;
return (
<div className="space-y-2" key={key}>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
required
type={
key === 'email'
? 'email'
: key === 'next_review'
? 'date'
: 'text'
}
value={form.data[name]}
onChange={(e) =>
form.setData(
name,
e.target.value,
)
}
/>
</div>
);
})}
</div>
<label className="flex items-center gap-3 text-sm">
<input
type="checkbox"
checked={form.data.blocked}
onChange={(e) =>
form.setData(
'blocked',
e.target.checked,
)
}
/>
Fornecedor consta da lista de impedidos
</label>
<p className="text-sm text-muted-foreground">
Registe as referências do alvará, situação
fiscal e declaração de conflitos que verificou.
Após o registo pode abrir a ficha para anexar os
documentos privados. Os dados bancários serão
tratados numa etapa posterior.
</p>
<Button disabled={form.processing}>
Guardar para validação
</Button>
</form>
</Panel>
)}
<Panel title="Directório de fornecedores">
<form
className="mb-5 flex gap-3"
onSubmit={(e) => {
e.preventDefault();
router.get(
'/fornecedores',
{ search },
{ preserveState: true },
);
}}
>
<Input
aria-label="Pesquisar fornecedor"
placeholder="Pesquisar nome ou NUIT"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Button variant="outline">Pesquisar</Button>
</form>
<div className="overflow-auto">
<table className="w-full text-left text-sm">
<thead className="border-b text-muted-foreground">
<tr>
{[
'Fornecedor',
'NUIT',
'Estado',
'Próxima revisão',
'',
].map((h, i) => (
<th className="pr-4 pb-3" key={i}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{suppliers.data.map((s) => (
<tr className="border-b" key={s.id}>
<td className="py-4 pr-4">
<strong>{s.name}</strong>
<p className="text-xs text-muted-foreground">
{s.category}
</p>
</td>
<td className="pr-4">{s.nuit}</td>
<td className="pr-4">
{labels[s.status]}
{s.blocked && (
<span className="block text-red-700">
Impedido
</span>
)}
</td>
<td className="pr-4">
{s.next_review.slice(0, 10)}
</td>
<td>
<Button
variant="outline"
onClick={() => {
setSelected(s);
review.reset();
review.clearErrors();
review.setData(
'version',
s.version,
);
}}
>
Ver ficha
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{!suppliers.data.length && (
<p className="py-8 text-center text-muted-foreground">
Nenhum fornecedor encontrado.
</p>
)}
<div className="mt-4 flex gap-4">
{suppliers.prev_page_url && (
<Link href={suppliers.prev_page_url}>Anterior</Link>
)}
{suppliers.next_page_url && (
<Link href={suppliers.next_page_url}>Seguinte</Link>
)}
</div>
</Panel>
{selected && (
<Panel
title={selected.name}
description={`NUIT ${selected.nuit}`}
>
<Button asChild variant="outline" className="mb-5">
<Link href={`/fornecedores/${selected.id}`}>
Abrir ficha, edição e documentos
</Link>
</Button>
<dl className="grid gap-4 text-sm md:grid-cols-2">
{Object.entries(fields).map(([key, label]) => (
<div key={key}>
<dt className="text-muted-foreground">
{label}
</dt>
<dd className="mt-1 whitespace-pre-wrap">
{selected[key as keyof typeof fields]}
</dd>
</div>
))}
</dl>
{selected.review_comment && (
<p className="mt-5 text-sm">
Última validação: {selected.review_comment}
</p>
)}
{canReview && (
<form
className="mt-6 space-y-4 border-t pt-5"
onSubmit={(e) => {
e.preventDefault();
review.post(
`/fornecedores/${selected.id}/validar`,
{ onSuccess: () => setSelected(null) },
);
}}
>
<FormErrors errors={review.errors} />
<Label htmlFor="review-status">
Resultado da validação
</Label>
<select
id="review-status"
className={controlClass}
value={review.data.status}
onChange={(e) =>
review.setData('status', e.target.value)
}
>
<option value="active">
Activar fornecedor
</option>
<option value="suspended">
Suspender fornecedor
</option>
</select>
<Label htmlFor="review-comment">
Fundamento da decisão
</Label>
<textarea
id="review-comment"
className={controlClass}
required
minLength={10}
value={review.data.comment}
onChange={(e) =>
review.setData(
'comment',
e.target.value,
)
}
/>
<Button disabled={review.processing}>
Registar decisão
</Button>
</form>
)}
</Panel>
)}
</div>
</>
);
}
Suppliers.layout = {
breadcrumbs: [{ title: 'Fornecedores', href: '/fornecedores' }],
};
+318
View File
@@ -0,0 +1,318 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { Download, FileLock2, Pencil, Upload } from 'lucide-react';
import { useRef } from 'react';
import {
FormErrors,
PageTitle,
Panel,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { date } from '@/types/procurement';
import {
documentTypes,
supplierFields,
supplierStatuses,
} from '@/types/supplier';
import type { Supplier } from '@/types/supplier';
type Document = {
id: number;
original_name: string;
document_type: string;
size: number;
created_at: string;
sha256: string;
};
type Props = {
supplier: Supplier;
documents: Document[];
canEdit: boolean;
canDocuments: boolean;
canReview: boolean;
};
export default function SupplierShow({
supplier,
documents,
canEdit,
canDocuments,
canReview,
}: Props) {
const upload = useForm<{ file: File | null; document_type: string }>({
file: null,
document_type: 'license',
});
const review = useForm({ status: 'active', comment: '' });
const fileInput = useRef<HTMLInputElement>(null);
return (
<>
<Head title={supplier.name} />
<div className="mx-auto flex w-full max-w-5xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow="Ficha de fornecedor"
title={supplier.name}
description={`NUIT ${supplier.nuit} · ${supplierStatuses[supplier.status]}${supplier.blocked ? ' · Impedido' : ''}`}
action={
canEdit ? (
<Button asChild>
<Link
href={`/fornecedores/${supplier.id}/editar`}
>
<Pencil className="size-4" />
Editar ficha
</Link>
</Button>
) : undefined
}
/>
<Panel title="Dados do fornecedor">
<dl className="grid gap-5 text-sm md:grid-cols-2">
{Object.entries(supplierFields).map(([key, label]) => (
<div key={key}>
<dt className="text-muted-foreground">
{label}
</dt>
<dd className="mt-1 whitespace-pre-wrap">
{key === 'next_review'
? date(supplier.next_review)
: supplier[
key as keyof typeof supplierFields
]}
</dd>
</div>
))}
</dl>
{supplier.review_comment && (
<p className="mt-5 border-t pt-4 text-sm">
Última validação: {supplier.review_comment}
</p>
)}
</Panel>
{canDocuments ? (
<Panel
title="Documentos privados"
description="Acesso restrito a Administração, Procurement e Finanças da empresa. As transferências ficam registadas em auditoria."
>
<div className="space-y-3">
{documents.map((document) => (
<div
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-4"
key={document.id}
>
<div className="flex min-w-0 items-start gap-3">
<FileLock2 className="mt-1 size-5 shrink-0 text-teal-700" />
<div className="min-w-0">
<p className="text-sm font-medium break-all">
{document.original_name}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{
documentTypes[
document.document_type
]
}{' '}
·{' '}
{Math.ceil(
document.size / 1024,
)}{' '}
KB · {date(document.created_at)}
</p>
</div>
</div>
<Button asChild variant="outline" size="sm">
<a
href={`/fornecedores/${supplier.id}/documentos/${document.id}`}
>
<Download className="size-4" />
Descarregar
<span className="sr-only">
{' '}
{document.original_name}
</span>
</a>
</Button>
</div>
))}
{documents.length === 0 && (
<p className="py-4 text-sm text-muted-foreground">
Ainda não existem documentos anexados a esta
ficha.
</p>
)}
</div>
{canEdit && (
<form
className="mt-6 space-y-4 border-t pt-5"
encType="multipart/form-data"
onSubmit={(e) => {
e.preventDefault();
upload.post(
`/fornecedores/${supplier.id}/documentos`,
{
forceFormData: true,
preserveScroll: true,
onSuccess: () => {
upload.reset();
if (fileInput.current)
fileInput.current.value =
'';
},
},
);
}}
>
<h3 className="text-sm font-semibold">
Anexar documento
</h3>
<FormErrors errors={upload.errors} />
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="document-type">
Tipo de documento
</Label>
<select
id="document-type"
className={controlClass}
value={upload.data.document_type}
onChange={(e) =>
upload.setData(
'document_type',
e.target.value,
)
}
>
{Object.entries(documentTypes).map(
([value, label]) => (
<option
value={value}
key={value}
>
{label}
</option>
),
)}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="supplier-file">
Ficheiro (PDF, JPEG ou PNG; até 2
MB)
</Label>
<Input
id="supplier-file"
ref={fileInput}
type="file"
accept=".pdf,.jpg,.jpeg,.png"
required
onChange={(e) =>
upload.setData(
'file',
e.target.files?.[0] ?? null,
)
}
/>
</div>
</div>
<p className="text-sm text-muted-foreground">
O envio de um documento devolve o fornecedor
ao estado pendente de validação. Os
documentos existentes permanecem no
histórico.
</p>
{upload.progress && (
<p className="text-xs" role="status">
Envio: {upload.progress.percentage}%
</p>
)}
<Button disabled={upload.processing}>
<Upload className="size-4" />
Enviar documento
</Button>
</form>
)}
</Panel>
) : (
<p className="text-sm text-muted-foreground">
O acesso aos documentos está reservado a Administração,
Procurement e Finanças.
</p>
)}
{canReview && (
<Panel
title="Validação independente por Finanças"
description="Confira os dados e os documentos actuais antes de decidir."
>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
review.transform((data) => ({
...data,
version: supplier.version,
}));
review.post(
`/fornecedores/${supplier.id}/validar`,
{
preserveScroll: true,
onSuccess: () => review.reset(),
},
);
}}
>
<FormErrors errors={review.errors} />
<div className="space-y-2">
<Label htmlFor="review-result">Resultado</Label>
<select
id="review-result"
className={controlClass}
value={review.data.status}
onChange={(e) =>
review.setData('status', e.target.value)
}
>
<option value="active">
Activar fornecedor
</option>
<option value="suspended">
Suspender fornecedor
</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="review-reason">
Fundamento da decisão
</Label>
<textarea
id="review-reason"
className={`${controlClass} h-24 py-2`}
required
minLength={10}
maxLength={2000}
value={review.data.comment}
onChange={(e) =>
review.setData(
'comment',
e.target.value,
)
}
/>
</div>
<Button disabled={review.processing}>
Registar decisão
</Button>
</form>
</Panel>
)}
</div>
</>
);
}
SupplierShow.layout = {
breadcrumbs: [
{ title: 'Fornecedores', href: '/fornecedores' },
{ title: 'Ficha e documentos', href: '#' },
],
};
+7
View File
@@ -2,6 +2,13 @@ export type User = {
id: number;
name: string;
email: string;
purchasing_role?:
| 'requester'
| 'department_head'
| 'procurement'
| 'finance'
| 'warehouse'
| 'admin';
avatar?: string;
email_verified_at: string | null;
created_at: string;
+22
View File
@@ -0,0 +1,22 @@
export type ConflictDeclaration = {
id: number;
user_id: number;
supplier_id: number | null;
requisition_id: number | null;
has_conflict: boolean;
known_since: string;
declared_at: string;
reason: string;
previous_id: number | null;
root_id: number | null;
author: { name: string };
supplier: { name: string } | null;
requisition: { number: string } | null;
successor?: { id: number } | null;
};
export function conflictScope(declaration: ConflictDeclaration) {
return [declaration.supplier?.name, declaration.requisition?.number]
.filter(Boolean)
.join(' · ');
}
+87
View File
@@ -0,0 +1,87 @@
export type Department = { id: number; name: string };
export type CostCenter = {
id: number;
department_id: number;
code: string;
name: string;
};
export type Company = {
id: number;
name: string;
first_limit_minor: number;
second_limit_minor: number;
approval_includes_tax: boolean;
rules_provisional: boolean;
};
export type RequisitionItem = {
id?: number;
description: string;
category: string;
purchase_type: string;
quantity: string;
unit: string;
unit_price_minor: number;
tax_rate: string;
subtotal_minor: number;
tax_minor: number;
total_minor: number;
};
export type ApprovalStep = {
id: number;
position: number;
role: string;
decision: string;
approver_id: number | null;
comment: string | null;
decided_at: string | null;
};
export type Requisition = {
id: number;
number: string;
status: string;
priority: string;
needed_by: string;
justification: string;
cost_center_id: number;
subtotal_minor: number;
tax_minor: number;
total_minor: number;
created_at: string;
requester?: { name: string };
department?: Department;
cost_center?: CostCenter;
items: RequisitionItem[];
steps: ApprovalStep[];
approval_snapshot?: {
rules_provisional: boolean;
approval_includes_tax: boolean;
} | null;
};
export const roleLabels: Record<string, string> = {
requester: 'Solicitante',
department_head: 'Chefe de departamento',
procurement: 'Procurement',
finance: 'Finanças',
warehouse: 'Recepção / Armazém',
admin: 'Administrador',
};
export const statusLabels: Record<string, string> = {
draft: 'Rascunho',
pending: 'Em aprovação',
approved: 'Aprovada',
rejected: 'Rejeitada',
};
export const priorityLabels: Record<string, string> = {
normal: 'Normal',
high: 'Alta',
urgent: 'Urgente',
};
export const money = (minor: number) =>
new Intl.NumberFormat('pt-MZ', {
style: 'currency',
currency: 'MZN',
}).format(minor / 100);
export const date = (value: string) =>
new Intl.DateTimeFormat('pt-MZ').format(
new Date(value.slice(0, 10) + 'T12:00:00'),
);
+25
View File
@@ -0,0 +1,25 @@
export type MatrixRow = {
id: number; reference: string; supplier: { name: string }; supplier_id: number;
eligible: boolean; ineligibility_reasons: string[]; evaluation_count: number;
score: number | null; score_minor: number | null; subtotal_minor: number; tax_minor: number; total_minor: number;
evaluations: { evaluator_id: number; quality: string; history: string; payment: string; comment: string }[];
};
export type AwardApproval = { id: number; position: number; role: string; decision: string; approver_id: number | null; comment: string | null };
export type PurchaseAward = {
id: number; requisition_id: number; quotation_id: number; status: string; justification: string;
subtotal_minor: number; tax_minor: number; total_minor: number; delivery_date: string; delivery_location: string;
approval_snapshot: { approval_includes_tax: boolean; rules_provisional: boolean }; approvals: AwardApproval[];
};
export type OrderItem = { id: number; description: string; quantity: string; unit: string; unit_price_minor: number; tax_rate: string; subtotal_minor: number; tax_minor: number; total_minor: number };
export type PurchaseOrder = {
id: number; number: string; requisition_id: number; issued_at: string; currency: string;
subtotal_minor: number; tax_minor: number; total_minor: number; items: OrderItem[];
snapshot: {
company: { name: string }; department: { name: string }; cost_center: { code: string; name: string };
requisition: { number: string }; issuer: { name: string }; requester: { name: string };
supplier: { name: string; nuit: string; contact: string; phone: string; email: string; address: string };
quotation: { reference: string; payment_terms: string }; award: { justification: string; delivery_date: string; delivery_location: string };
approval_rules: { approval_includes_tax: boolean; rules_provisional: boolean };
decision_matrix: MatrixRow[]; final_approvals: AwardApproval[];
};
};
+43
View File
@@ -0,0 +1,43 @@
export type Supplier = {
id: number;
name: string;
nuit: string;
contact: string;
email: string;
phone: string;
address: string;
category: string;
beneficiaries: string;
document_references: string;
diligence_notes: string;
blocked: boolean;
next_review: string;
status: string;
review_comment: string | null;
version: number;
};
export const supplierFields = {
name: 'Nome legal',
nuit: 'NUIT (9 dígitos)',
contact: 'Pessoa de contacto',
email: 'Email',
phone: 'Telefone',
address: 'Morada',
category: 'Categoria de fornecimento',
beneficiaries: 'Beneficiários efectivos',
document_references: 'Referências dos documentos verificados',
diligence_notes: 'Resultado da diligência devida',
next_review: 'Próxima revisão',
};
export const supplierStatuses: Record<string, string> = {
pending: 'Pendente de validação',
active: 'Activo',
suspended: 'Suspenso',
};
export const documentTypes: Record<string, string> = {
license: 'Alvará / licença',
tax: 'Situação fiscal',
conflict: 'Declaração de conflitos',
other: 'Outro documento',
};
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="pt">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $order->number }} · ProcuraFlow</title>
<style>
@page { size: A4; margin: 17mm; }
* { box-sizing: border-box; }
body { color: #111; font: 12px/1.5 Arial, sans-serif; margin: 24px auto; max-width: 900px; }
h1 { font-size: 25px; margin-bottom: 2px; } h2 { font-size: 16px; margin-top: 24px; }
.notice { border: 1px solid #aaa; padding: 12px; margin: 18px 0; }
.columns { display: flex; gap: 30px; } .columns > div { flex: 1; }
table { border-collapse: collapse; width: 100%; margin: 14px 0; }
th, td { border: 1px solid #ddd; text-align: left; padding: 8px; vertical-align: top; }
th { background: #eee; } thead { display: table-header-group; } tr { break-inside: avoid; }
.totals { margin-left: auto; width: 330px; } .pre { white-space: pre-wrap; overflow-wrap: anywhere; }
.small { font-size: 10px; color: #444; } button { padding: 10px 18px; cursor: pointer; }
@media print { body { margin: 0; max-width: none; } .no-print { display: none; } }
</style>
</head>
<body>
@php($money = fn ($minor) => number_format($minor / 100, 2, ',', '.') . ' MZN')
<button type="button" class="no-print" onclick="window.print()">Imprimir ou guardar como PDF</button>
<h1>Ordem de compra {{ $order->number }}</h1>
<p>{{ $snapshot['company']['name'] }} · {{ $snapshot['requisition']['number'] }}</p>
<div class="notice"><strong>Documento de desenvolvimento. Não autoriza compras reais.</strong><br>Orçamento não verificado. @if($snapshot['approval_rules']['rules_provisional']) Regras de aprovação provisórias. @endif</div>
<div class="columns">
<div><h2>Fornecedor</h2><strong>{{ $snapshot['supplier']['name'] }}</strong><br>NUIT {{ $snapshot['supplier']['nuit'] }}<br><span class="pre">{{ $snapshot['supplier']['address'] }}</span><br>{{ $snapshot['supplier']['contact'] }}<br>{{ $snapshot['supplier']['phone'] }} · {{ $snapshot['supplier']['email'] }}</div>
<div><h2>Condições</h2>Entrega: {{ $snapshot['award']['delivery_date'] }}<br><span class="pre">{{ $snapshot['award']['delivery_location'] }}</span><br>Pagamento: {{ $snapshot['quotation']['payment_terms'] }}<br>Proposta: {{ $snapshot['quotation']['reference'] }}<br>{{ $snapshot['department']['name'] }} · {{ $snapshot['cost_center']['code'] }}<br>Emitente: {{ $snapshot['issuer']['name'] }}<br>Emissão: {{ $order->issued_at->format('Y-m-d H:i:s') }}</div>
</div>
<h2>Artigos e valores</h2>
<table><thead><tr><th>Descrição</th><th>Quantidade</th><th>Preço sem IVA</th><th>IVA</th><th>Total com IVA</th></tr></thead><tbody>
@foreach($order->items as $item)
<tr><td class="pre">{{ $item->description }}</td><td>{{ $item->quantity }} {{ $item->unit }}</td><td>{{ $money($item->unit_price_minor) }}</td><td>{{ $item->tax_rate }}%</td><td>{{ $money($item->total_minor) }}</td></tr>
@endforeach
</tbody></table>
<table class="totals"><tr><td>Subtotal sem IVA</td><td>{{ $money($order->subtotal_minor) }}</td></tr><tr><td>IVA</td><td>{{ $money($order->tax_minor) }}</td></tr><tr><th>Total com IVA</th><th>{{ $money($order->total_minor) }}</th></tr></table>
<h2>Justificação da adjudicação</h2><p class="pre">{{ $snapshot['award']['justification'] }}</p>
<h2>Matriz de avaliação congelada</h2>
<table><thead><tr><th>Fornecedor / proposta</th><th>Total com IVA</th><th>Avaliações elegíveis</th><th>Pontuação</th></tr></thead><tbody>
@foreach($snapshot['decision_matrix'] as $row)
<tr><td>{{ $row['supplier']['name'] }} · {{ $row['reference'] }}<br>@foreach($row['evaluations'] as $evaluation)<span class="small">Avaliador #{{ $evaluation['evaluator_id'] }}: qualidade {{ $evaluation['quality'] }}, histórico {{ $evaluation['history'] }}, pagamento {{ $evaluation['payment'] }}. {{ $evaluation['comment'] }}</span><br>@endforeach</td><td>{{ $money($row['total_minor']) }}</td><td>{{ $row['evaluation_count'] }}</td><td>{{ $row['score'] === null ? 'Não elegível / incompleta' : number_format($row['score'], 2, ',', '.') . ' / 10' }}</td></tr>
@endforeach
</tbody></table>
<h2>Aprovações do valor final</h2><ul>@foreach($snapshot['final_approvals'] as $approval)<li>{{ \App\Enums\PurchasingRole::from($approval['role'])->label() }} · Utilizador #{{ $approval['approver_id'] }} · {{ $approval['decided_at'] }} · {{ $approval['comment'] }}</li>@endforeach</ul>
<p class="small">Base dos escalões: {{ $snapshot['approval_rules']['approval_includes_tax'] ? 'com IVA' : 'sem IVA' }}. Dados congelados na emissão; alterações posteriores às fichas não reescrevem este documento. Não foi enviado por email.</p>
</body>
</html>