@@ -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,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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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' }],
|
||||
};
|
||||
@@ -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' }],
|
||||
};
|
||||
@@ -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' }],
|
||||
};
|
||||
@@ -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: '#' },
|
||||
],
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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: '#' }] };
|
||||
@@ -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' }] };
|
||||
@@ -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: '#' }] };
|
||||
@@ -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' }] };
|
||||
@@ -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: '#' },
|
||||
],
|
||||
};
|
||||
@@ -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: '#' },
|
||||
],
|
||||
};
|
||||
@@ -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' }],
|
||||
};
|
||||
@@ -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: '#' },
|
||||
],
|
||||
};
|
||||
@@ -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: '#' },
|
||||
],
|
||||
};
|
||||
@@ -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' }],
|
||||
};
|
||||
@@ -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: '#' },
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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(' · ');
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
@@ -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[];
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
Reference in New Issue
Block a user