merge: reconcile local fiscal corrections with delivered portal
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
import { profileLinks } from '@/components/profile-navigation';
|
||||
import {PortalSidebarNavigation} from '@/components/portal-sidebar-navigation';
|
||||
import type { ProfileAccess } from '@/types/profile-access';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { portalNavigation } from '@/lib/portal-navigation';
|
||||
import { portalNavigation,canShowLegacyManagement } from '@/lib/portal-navigation';
|
||||
import AppLogo from '@/components/app-logo';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -20,6 +18,7 @@ import type { Auth } from '@/types';
|
||||
import type { OpportunityManagementAccess } from '@/types/opportunity-management';
|
||||
|
||||
export function AppSidebar() {
|
||||
const currentPage=usePage();
|
||||
const {
|
||||
auth,
|
||||
existingBusinessEnabled,
|
||||
@@ -27,7 +26,9 @@ export function AppSidebar() {
|
||||
aiEnabled,
|
||||
opportunityManagement,
|
||||
contentManagementHref,
|
||||
contentManagementAccess,
|
||||
profileAccess,
|
||||
scopedAdministration,
|
||||
} = usePage<{
|
||||
auth: Auth;
|
||||
existingBusinessEnabled?: boolean;
|
||||
@@ -36,45 +37,26 @@ export function AppSidebar() {
|
||||
profileAccess?: ProfileAccess | null;
|
||||
opportunityManagement?: OpportunityManagementAccess | null;
|
||||
contentManagementHref?: string | null;
|
||||
scopedAdministration?: {label:string;href:string}[];
|
||||
contentManagementAccess?: {opportunities?:{index:string;create?:string|null}};
|
||||
}>().props;
|
||||
const groups = portalNavigation({
|
||||
authenticated: !!auth.user,
|
||||
manager: auth.canManagePortal,
|
||||
manager: canShowLegacyManagement(auth),
|
||||
businesses: existingBusinessEnabled,
|
||||
education: educationEnabled,
|
||||
ai: aiEnabled,
|
||||
opportunityManagement,
|
||||
profileAccess,
|
||||
contentManagementHref,
|
||||
contentManagementAccess,
|
||||
scopedAdministration,
|
||||
legacyContent: currentPage.props.legacyContentEnabled as boolean|undefined,
|
||||
procurement: currentPage.props.procurementEnabled as boolean|undefined,
|
||||
businessReviewUrl: currentPage.props.businessReviewUrl as string|null|undefined,
|
||||
});
|
||||
const items = groups.map((group) => ({
|
||||
title: group.label,
|
||||
href: group.children?.find((item) => item.href)?.href ?? '/',
|
||||
icon: LayoutGrid,
|
||||
}));
|
||||
if (contentManagementHref) {
|
||||
items.unshift({ title: 'Gestão de conteúdos e oportunidades', href: contentManagementHref, icon: LayoutGrid });
|
||||
}
|
||||
if (auth.canManagePortal && opportunityManagement) {
|
||||
items.push({
|
||||
title: opportunityManagement.manage.label,
|
||||
href: opportunityManagement.manage.href,
|
||||
icon: LayoutGrid,
|
||||
});
|
||||
if (opportunityManagement.installed) {
|
||||
items.push({
|
||||
title: opportunityManagement.create.label,
|
||||
href: opportunityManagement.create.href,
|
||||
icon: LayoutGrid,
|
||||
});
|
||||
}
|
||||
}
|
||||
items.push(
|
||||
...profileLinks(profileAccess).map((link) => ({
|
||||
...link,
|
||||
icon: LayoutGrid,
|
||||
})),
|
||||
);
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<Sidebar collapsible="offcanvas" variant="inset">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
@@ -88,7 +70,7 @@ export function AppSidebar() {
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain items={items} />
|
||||
<PortalSidebarNavigation groups={groups} url={currentPage.url}/>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import type { EducationalArticle } from './educational-article-card';
|
||||
|
||||
/** Guides remain available on request; they are not matching investment results. */
|
||||
export default function CatalogueGuideLinks({ articles }: { articles: EducationalArticle[] }) {
|
||||
if (!articles.length) return null;
|
||||
return <details className="rounded border p-3">
|
||||
<summary className="cursor-pointer font-medium">Guias do sector — apoio opcional</summary>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{articles.map((article) => <li key={article.href}>
|
||||
<Link className="underline" href={article.href}>Ler guia: {article.title}</Link>
|
||||
</li>)}
|
||||
</ul>
|
||||
</details>;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { forceLabels, forces } from '@/lib/cluster-competitiveness';
|
||||
import type { CompetitivenessDraft } from '@/lib/cluster-competitiveness';
|
||||
|
||||
export type ApprovedCompetitiveness = {
|
||||
draft: CompetitivenessDraft;
|
||||
version: number;
|
||||
at: string;
|
||||
};
|
||||
const pressures = {
|
||||
low: 'Baixa',
|
||||
medium: 'Média',
|
||||
high: 'Alta',
|
||||
'': 'Indeterminada',
|
||||
};
|
||||
|
||||
export default function ClusterCompetitivenessPublic({
|
||||
analysis,
|
||||
}: {
|
||||
analysis: ApprovedCompetitiveness;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="space-y-3 rounded border p-4"
|
||||
aria-label="Competitividade aprovada"
|
||||
>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Competitividade — cinco forças de Porter
|
||||
</h2>
|
||||
<p>
|
||||
{analysis.draft.sector} · {analysis.draft.region} · Versão
|
||||
aprovada {analysis.version} · {analysis.at}
|
||||
</p>
|
||||
<p>
|
||||
Apreciação qualitativa fundamentada nas fontes e períodos
|
||||
indicados. Não representa garantia de procura ou viabilidade.
|
||||
</p>
|
||||
{forces.map((force) => {
|
||||
const row = analysis.draft.forces[force];
|
||||
return (
|
||||
<details key={force} className="rounded border p-3">
|
||||
<summary>
|
||||
{forceLabels[force]} · Pressão{' '}
|
||||
{pressures[row.pressure]}
|
||||
</summary>
|
||||
<dl className="space-y-2">
|
||||
{(
|
||||
[
|
||||
['evidence', 'Evidências'],
|
||||
['source', 'Fonte e referência'],
|
||||
['period', 'Período'],
|
||||
['rationale', 'Justificação'],
|
||||
['limitations', 'Limitações'],
|
||||
[
|
||||
'opportunity',
|
||||
'Oportunidade e fundamento',
|
||||
],
|
||||
] as const
|
||||
).map(([field, label]) => (
|
||||
<div key={field}>
|
||||
<dt className="font-semibold">{label}</dt>
|
||||
<dd className="whitespace-pre-wrap">
|
||||
{row[field]}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,5 +2,5 @@ import { Link, usePage } from '@inertiajs/react';
|
||||
|
||||
export default function ContentManagementEntry() {
|
||||
const { contentManagementHref } = usePage<{ contentManagementHref?: string | null }>().props;
|
||||
return contentManagementHref ? <Link className="inline-block rounded-lg border px-4 py-3 font-semibold underline" href={contentManagementHref}>Gestão de conteúdos e oportunidades</Link> : null;
|
||||
return contentManagementHref ? <Link className="inline-block rounded-lg border px-4 py-3 font-semibold underline" href={contentManagementHref}>Área de trabalho</Link> : null;
|
||||
}
|
||||
|
||||
@@ -13,9 +13,12 @@ export type EducationalArticle = {
|
||||
|
||||
export default function EducationalArticleCard({
|
||||
article,
|
||||
headingLevel = 3,
|
||||
}: {
|
||||
article: EducationalArticle;
|
||||
headingLevel?: 3 | 4;
|
||||
}) {
|
||||
const Heading = headingLevel === 3 ? 'h3' : 'h4';
|
||||
const focus =
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-800';
|
||||
return (
|
||||
@@ -34,11 +37,11 @@ export default function EducationalArticleCard({
|
||||
<p className="text-sm font-medium text-emerald-800">
|
||||
Guia sectorial
|
||||
</p>
|
||||
<h3 className="text-2xl leading-tight font-bold">
|
||||
<Heading className="text-2xl leading-tight font-bold">
|
||||
<Link href={article.href} className={focus}>
|
||||
{article.title}
|
||||
</Link>
|
||||
</h3>
|
||||
</Heading>
|
||||
{article.summary && (
|
||||
<p className="text-base leading-relaxed text-slate-700">
|
||||
{article.summary}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type {FiscalProofParcel} from '@/lib/fiscal-proof-parcel';
|
||||
|
||||
export function FiscalProofParcelFields({value, change, label}: {value: FiscalProofParcel; change: (value: FiscalProofParcel) => void; label: string}) {
|
||||
return <details className="rounded border p-2" open={!!value.parcel || !!value.parcelReference}>
|
||||
<summary>Identificar parcela quando o documento suporta várias parcelas — {label}</summary>
|
||||
<p>Sem identificação específica, o comprovativo existente abrange o documento inteiro. Para distinguir impostos ou parcelas, indique a linha/identificador exacto e o suporte dessa separação. Os montantes comprovado e seleccionado devem corresponder à parcela identificada. A mudança do identificador não comprova uma parcela diferente.</p>
|
||||
<label className="block">Identificador da parcela<input aria-label={label + ' — Parcela documental'} value={value.parcel ?? ''} onChange={e => change({parcel: e.target.value})}/></label>
|
||||
<label className="block">Suporte da separação das parcelas<input aria-label={label + ' — Suporte da parcela'} value={value.parcelReference ?? ''} onChange={e => change({parcelReference: e.target.value})}/></label>
|
||||
</details>;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useId, useState } from 'react';
|
||||
import { api } from '@/pages/investment/shared';
|
||||
import { replaceTerritoryPath } from '@/lib/territory-path.js';
|
||||
import TerritoryGeometryPreview from './territory-geometry-preview';
|
||||
import TerritorySearchSelect from './territory-search-select';
|
||||
|
||||
export type TerritoryOption = {
|
||||
id: string;
|
||||
@@ -135,37 +136,11 @@ export function ProvinceSelect({
|
||||
total={options.length}
|
||||
retry={() => setRetry((value) => value + 1)}
|
||||
/>
|
||||
<input
|
||||
className={className}
|
||||
aria-label={`Pesquisar ${label}`}
|
||||
maxLength={100}
|
||||
placeholder="Pesquisar nome ou código"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
id={id}
|
||||
aria-label={label}
|
||||
className={className}
|
||||
required={required}
|
||||
value={value}
|
||||
disabled={loading || Boolean(error)}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{loading ? 'A carregar províncias…' : placeholder}
|
||||
</option>
|
||||
{value && !options.some((option) => option.name === value) && (
|
||||
<option value={value}>
|
||||
{value} (seleção guardada; conferir cadastro)
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={option.id} value={option.name}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<TerritorySearchSelect id={id} label={label} className={className} required={required}
|
||||
placeholder={placeholder === 'Seleccione uma província' ? 'Pesquisar província' : placeholder}
|
||||
search={search} onSearch={setSearch} options={options} loading={loading} error={error}
|
||||
selected={value ? options.find((option) => option.name === value) ?? { id: value, name: value } : undefined}
|
||||
onChange={(option) => onChange(option?.name ?? '')} />
|
||||
{geometryFeedback && <p role="status">{geometryFeedback}</p>}
|
||||
<TerritoryGeometryPreview
|
||||
territoryId={
|
||||
@@ -334,65 +309,15 @@ export function TerritoryLevel({
|
||||
<label htmlFor={id} className="font-medium">
|
||||
{labels[index]}
|
||||
</label>
|
||||
<input
|
||||
className={control}
|
||||
aria-label={`Pesquisar ${labels[index]}`}
|
||||
disabled={!enabled}
|
||||
maxLength={100}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder={`Pesquisar ${labels[index].toLowerCase()}`}
|
||||
/>
|
||||
<select
|
||||
id={id}
|
||||
aria-invalid={Boolean(fieldError)}
|
||||
aria-describedby={fieldError ? `${id}-error` : undefined}
|
||||
className={control}
|
||||
disabled={!enabled || loading || !!error}
|
||||
value={selected?.id ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange(options.find((o) => o.id === e.target.value))
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{enabled ? 'Seleccione' : 'Seleccione o nível anterior'}
|
||||
</option>
|
||||
{selected && !options.some((o) => o.id === selected.id) && (
|
||||
<option value={selected.id}>
|
||||
{selected.name}
|
||||
{selected.deleted_at
|
||||
? ' (arquivada)'
|
||||
: ' (selecção actual)'}
|
||||
</option>
|
||||
)}
|
||||
{options.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
{o.source_code ? ` (${o.source_code})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<TerritorySearchSelect id={id} label={labels[index]} className={control}
|
||||
disabled={!enabled} search={search} onSearch={(query) => { setSearch(query); setPage(1); }}
|
||||
options={options} selected={selected} onChange={onChange} loading={loading} error={fieldError || error}>
|
||||
<div className="flex gap-3 p-2 text-sm">
|
||||
{page > 1 && <button type="button" onClick={() => setPage(page - 1)}>Anterior</button>}
|
||||
{more && <button type="button" onClick={() => setPage(page + 1)}>Mais opções</button>}
|
||||
</div>
|
||||
</TerritorySearchSelect>
|
||||
{loading && <p role="status">A carregar…</p>}
|
||||
{fieldError && (
|
||||
<p id={`${id}-error`} role="alert">
|
||||
{fieldError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3 text-sm">
|
||||
{page > 1 && (
|
||||
<button type="button" onClick={() => setPage(page - 1)}>
|
||||
Anterior
|
||||
</button>
|
||||
)}
|
||||
{more && (
|
||||
<button type="button" onClick={() => setPage(page + 1)}>
|
||||
Mais opções
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { fiscalPreparationGuide } from '@/lib/fiscal-preparation-guide';
|
||||
|
||||
@@ -8,6 +8,8 @@ export function JourneyDocumentResources({
|
||||
stages?: string[];
|
||||
}) {
|
||||
const [stage, setStage] = useState('');
|
||||
const context=new URL(usePage().url,'https://portal.invalid').searchParams.get('context');
|
||||
const preparationHref=context&&/^[a-f0-9-]{36}$/i.test(context)?`/investimentos/${context}/contexto/editar#preparation-questions`:'/investimentos';
|
||||
function destination(path: string, type?: string) {
|
||||
const query = new URLSearchParams();
|
||||
if (stage) query.set('stage[0]', stage);
|
||||
@@ -20,8 +22,24 @@ export function JourneyDocumentResources({
|
||||
className="space-y-3 rounded-xl border border-emerald-200 bg-emerald-50 p-4"
|
||||
>
|
||||
<h2 className="font-semibold text-emerald-950">
|
||||
Fontes e guias para a jornada
|
||||
Preencher formulários e preparar documentos
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded border bg-white p-3"><h3 className="font-semibold">Pedido de investimento</h3>
|
||||
<p>Preencha a preparação digital no dossier existente, reutilizando os dados já guardados.</p>
|
||||
<Link className="underline" href={preparationHref}>Abrir o formulário no meu dossier</Link>
|
||||
</div>
|
||||
<div className="rounded border bg-white p-3"><h3 className="font-semibold">Terra e DUAT</h3>
|
||||
<p>Prepare os dados do pedido com os campos do formulário existente.</p>
|
||||
<Link className="underline" href="/terra-duat">Preencher ou continuar pedido de terra</Link>
|
||||
</div>
|
||||
<div className="rounded border bg-white p-3"><h3 className="font-semibold">Pedido de apoio do investidor</h3>
|
||||
<p>Identifique-se e preencha o pedido de apoio. Este formulário não é um pedido de autorização de investimento.</p>
|
||||
<Link className="underline" href="/formularios/investidor">Preencher pedido de apoio</Link>
|
||||
</div>
|
||||
</div>
|
||||
<p>Preparação digital e exemplar oficial são apresentados separadamente. Um guia ou diploma não é um formulário preenchível.</p>
|
||||
<details><summary className="cursor-pointer font-medium">Consultar exemplares, fontes e guias</summary>
|
||||
<p>
|
||||
Prepare as etapas em paralelo. A disponibilidade para consulta,
|
||||
a situação jurídica e a utilização numa regra ou cálculo são
|
||||
@@ -143,6 +161,7 @@ export function JourneyDocumentResources({
|
||||
<Link className="underline" href="/documentos">
|
||||
Explorar todo o acervo autorizado
|
||||
</Link>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import { valueText } from '@/lib/value-text';
|
||||
import OpportunityParcelGeometry from './opportunity-parcel-geometry';
|
||||
import TerritoryGeometryPreview from './territory-geometry-preview';
|
||||
import { control } from '@/pages/management/shared';
|
||||
import OpportunityFillFlow from './opportunity-fill-flow';
|
||||
import { opportunityFillGroups } from '@/lib/opportunity-fill-flow';
|
||||
import { modalityLabels } from './opportunity-search-filters';
|
||||
|
||||
type Data = Record<string, unknown>;
|
||||
type Schema = {
|
||||
@@ -241,6 +244,11 @@ const labels: Record<string, string> = {
|
||||
assessment_status: 'Estado da avaliação',
|
||||
environmental_category: 'Categoria ambiental',
|
||||
direct_jobs: 'Empregos diretos',
|
||||
indirect_jobs: 'Empregos indirectos',
|
||||
female_percentage: 'Participação de mulheres (%)',
|
||||
youth_percentage: 'Participação de jovens (%)',
|
||||
social_risks: 'Riscos sociais',
|
||||
mitigation_plan: 'Plano de mitigação',
|
||||
employment_type: 'Tipo de emprego',
|
||||
geometry: 'Geometria da parcela',
|
||||
start_date: 'Data de início',
|
||||
@@ -621,6 +629,8 @@ export default function OpportunityCanonicalFields({
|
||||
sectors,
|
||||
disabled = false,
|
||||
errors = {},
|
||||
onOpenResources,
|
||||
missingPaths,
|
||||
}: {
|
||||
value: Data;
|
||||
onChange: (v: Data) => void;
|
||||
@@ -631,6 +641,8 @@ export default function OpportunityCanonicalFields({
|
||||
sectors: { id: number; name: string }[];
|
||||
disabled?: boolean;
|
||||
errors?: Record<string, string>;
|
||||
onOpenResources?: (group: string) => void;
|
||||
missingPaths?: string[];
|
||||
}) {
|
||||
const initiative = (value.initiative ?? {}) as Data;
|
||||
const isInitiative = category === 'investment_project' && ['program', 'portfolio'].includes(valueText(initiative.kind ?? ''));
|
||||
@@ -815,7 +827,7 @@ export default function OpportunityCanonicalFields({
|
||||
: ['classified_other'];
|
||||
return (
|
||||
<fieldset disabled={disabled} className="space-y-4 rounded border p-4">
|
||||
<legend>Contrato da oportunidade — {category}</legend>
|
||||
<legend>Preparar oportunidade — {modalityLabels[category] ?? 'Modalidade por escolher'}</legend>
|
||||
<p>
|
||||
O rascunho pode ficar incompleto. A submissão verifica os campos
|
||||
aplicáveis e os recursos guardados. Validação interna não
|
||||
@@ -870,7 +882,7 @@ export default function OpportunityCanonicalFields({
|
||||
</>}
|
||||
</section>
|
||||
)}
|
||||
{[...new Set([...common, ...specialized])].map((key) => {
|
||||
<OpportunityFillFlow groups={opportunityFillGroups([...new Set([...common, ...specialized])].filter((key) => !ignoredFields.includes(key)))} errors={errors} missingPaths={missingPaths} onOpenResources={onOpenResources} renderField={(key) => {
|
||||
if (ignoredFields.includes(key)) return null;
|
||||
|
||||
if (key === 'relations' && options.classification) {
|
||||
@@ -1167,7 +1179,7 @@ export default function OpportunityCanonicalFields({
|
||||
onChange={(v) => set(key, v)}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
}} />
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Link } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { button, card, input } from '@/pages/investment/shared';
|
||||
|
||||
type Item = {id:number;title:string;slug:string;summary:string|null;province:string|null;district:string|null;chains:string[];stages:string[];maturity_label:string|null};
|
||||
type Item = {id:number;title:string;slug:string;summary:string|null;province:string|null;district:string|null;provinces?:string[];territorial_summary?:string;chains:string[];stages:string[];maturity_label:string|null};
|
||||
export type CorridorGroup = {id:string;name:string;count:number;locations:string[];opportunities:Item[]};
|
||||
type Props = {groups:CorridorGroup[];selected:number[];onSelect:(id:number,checked:boolean)=>void;onCompare:(ids:number[])=>void;onClear:()=>void};
|
||||
|
||||
@@ -10,10 +10,11 @@ function Group({group,selected,onSelect}: {group:CorridorGroup} & Pick<Props,'se
|
||||
const [province,setProvince]=useState('');
|
||||
const [district,setDistrict]=useState('');
|
||||
const [chain,setChain]=useState('');
|
||||
const provinces=[...new Set(group.opportunities.map(r=>r.province).filter((s):s is string=>!!s))].sort();
|
||||
const districts=[...new Set(group.opportunities.filter(r=>!province||r.province===province).map(r=>r.district).filter((s):s is string=>!!s))].sort();
|
||||
const provinceNames=(r:Item)=>r.provinces??(r.province?[r.province]:[]);
|
||||
const provinces=[...new Set(group.opportunities.flatMap(provinceNames))].sort();
|
||||
const districts=[...new Set(group.opportunities.filter(r=>!province||provinceNames(r).includes(province)).map(r=>r.district).filter((s):s is string=>!!s))].sort();
|
||||
const chains=[...new Set(group.opportunities.flatMap(r=>r.chains))].sort();
|
||||
const rows=group.opportunities.filter(r=>(!province||r.province===province)&&(!district||r.district===district)&&(!chain||r.chains.includes(chain)));
|
||||
const rows=group.opportunities.filter(r=>(!province||provinceNames(r).includes(province))&&(!district||r.district===district)&&(!chain||r.chains.includes(chain)));
|
||||
return <section className={card+' space-y-4'} aria-label={group.name}>
|
||||
<header><h2 className="text-2xl font-bold">{group.name}</h2><p>{group.count} oportunidades publicadas</p>
|
||||
<p>{group.locations.length ? 'Localizações identificadas: '+group.locations.join(' · ') : 'Abrangência territorial por indicar.'}</p></header>
|
||||
@@ -25,7 +26,7 @@ function Group({group,selected,onSelect}: {group:CorridorGroup} & Pick<Props,'se
|
||||
<p role="status">{rows.length} oportunidades com estes filtros</p>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{rows.map(r=><article key={r.id} className="space-y-3 rounded-lg border p-4">
|
||||
<h3 className="text-lg font-semibold"><Link href={'/oportunidades/'+r.slug}>{r.title}</Link></h3>
|
||||
<p>{[r.province,r.district].filter(Boolean).join(' / ')||'Localização por escolher'}</p>
|
||||
<p>{r.territorial_summary||[r.province,r.district].filter(Boolean).join(' / ')||'Localização por escolher'}</p>
|
||||
<p>Cadeia de valor: {r.chains.join(' · ')||'Por indicar'}</p>
|
||||
{r.summary && !r.stages.includes(r.summary) && <p>{r.summary}</p>}
|
||||
<p>Etapas e actividades: {r.stages.join(' · ')||'Por indicar'}</p><p>{r.maturity_label}</p>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { opportunityErrorGroup, opportunityMissingGroups, type FillGroup } from '@/lib/opportunity-fill-flow';
|
||||
|
||||
export default function OpportunityFillFlow({groups,errors,renderField,onOpenResources,missingPaths=[]}:{
|
||||
groups:FillGroup[];errors:Record<string,string>;renderField:(field:string)=>ReactNode;onOpenResources?:(group:string)=>void;
|
||||
missingPaths?:string[];
|
||||
}) {
|
||||
const [active,setActive]=useState(groups[0]?.id??'');
|
||||
const groupIds=groups.map(group=>group.id).join(',');
|
||||
useEffect(()=>{
|
||||
const saved=window.location.hash.replace('#fill-','');
|
||||
if(groupIds.split(',').includes(saved))setActive(saved);
|
||||
},[groupIds]);
|
||||
const heading=useRef<HTMLHeadingElement|null>(null);
|
||||
const errorGroup=opportunityErrorGroup(groups,errors);
|
||||
const missing=opportunityMissingGroups(groups,missingPaths);
|
||||
const errorKey=JSON.stringify(errors);
|
||||
useEffect(()=>{if(errorGroup)setActive(errorGroup);},[errorGroup,errorKey]);
|
||||
const index=Math.max(0,groups.findIndex(group=>group.id===active));
|
||||
const current=groups[index];
|
||||
if(!current)return null;
|
||||
const go=(id:string)=>{
|
||||
setActive(id);
|
||||
window.history.replaceState(window.history.state,'',`#fill-${id}`);
|
||||
requestAnimationFrame(()=>heading.current?.focus());
|
||||
};
|
||||
return <div className="space-y-4">
|
||||
<p>Pode escolher qualquer grupo, guardar incompleto e retomar. As informações já preenchidas mantêm-se ao avançar e regressar.</p>
|
||||
<nav aria-label="Preencher a oportunidade" className="flex flex-wrap gap-2">
|
||||
{groups.map(group=><a key={group.id} href={`#fill-${group.id}`} aria-current={current.id===group.id?'step':undefined}
|
||||
className={`rounded border px-3 py-2 text-left ${current.id===group.id?'bg-primary text-primary-foreground':'bg-background'}`}
|
||||
onClick={event=>{event.preventDefault();go(group.id);}}>{group.title}{opportunityErrorGroup([group],errors)?' — verificar':missing.some(item=>item.id===group.id)?' — dados em falta':''}</a>)}
|
||||
</nav>
|
||||
{missing.length>0&&<p>Pendências da última gravação: {missing.map(group=>group.title).join(' · ')}. Guarde para actualizar esta indicação.</p>}
|
||||
<h2 ref={heading} tabIndex={-1} className="text-xl font-semibold">{current.title}</h2>
|
||||
<p>{current.hint}</p>
|
||||
{groups.map(group=><section key={group.id} id={`fill-${group.id}`} hidden={current.id!==group.id}
|
||||
aria-label={group.title} className="space-y-4">
|
||||
{group.fields.map(field=><div key={field}>{renderField(field)}</div>)}
|
||||
{onOpenResources&&group.fields.some(field=>['locations','documents','images'].includes(field))&&
|
||||
<button type="button" className="rounded border px-4 py-2 font-medium" onClick={()=>onOpenResources(group.id)}>Guardar e abrir documentos, imagens e localizações</button>}
|
||||
</section>)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-3">
|
||||
<span className="text-sm">Grupo {index+1} de {groups.length} · navegação livre</span>
|
||||
<div className="flex gap-3">
|
||||
{index>0&&<a href={`#fill-${groups[index-1].id}`} className="rounded border px-4 py-2" onClick={event=>{event.preventDefault();go(groups[index-1].id);}}>Anterior</a>}
|
||||
{index<groups.length-1&&<a href={`#fill-${groups[index+1].id}`} className="rounded border px-4 py-2" onClick={event=>{event.preventDefault();go(groups[index+1].id);}}>Continuar: {groups[index+1].title}</a>}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export const modalityLabels: Record<string, string> = {
|
||||
type Values = Record<string, string | boolean | number[]>;
|
||||
const labels: Record<string, string> = {
|
||||
q: 'O que procura',
|
||||
perspective: 'Consultar por',
|
||||
sector: 'Sector',
|
||||
category: 'Modalidade da oportunidade',
|
||||
preparation: 'Estado de preparação',
|
||||
@@ -215,11 +216,17 @@ export default function OpportunitySearchFilters({
|
||||
onChange={(e) => change({ q: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
{select('perspective', [{value:'exists',label:'O que existe'},{value:'needs',label:'Necessidades identificadas'},{value:'ideas',label:'Ideias de investimento'},{value:'opportunities',label:'Oportunidades cadastradas — disponibilidade na ficha'}])}
|
||||
{select('sector', choicesFor('sector'))}
|
||||
{select('category', choicesFor('category'))}
|
||||
</div>
|
||||
<fieldset className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<legend className="mb-2 font-semibold">Localização</legend>
|
||||
<p className="text-sm sm:col-span-2 lg:col-span-4">
|
||||
A província inclui as oportunidades dos seus distritos, postos administrativos e localidades.
|
||||
Não é necessário escolher todos os níveis. O contexto provincial de um corredor é identificado nos resultados; não confirma uma implantação distrital.
|
||||
{path.length > 1 && <button type="button" className="ml-2 underline" onClick={() => territory(path.slice(0, 1))}>Ver toda a província de {path[0].name}</button>}
|
||||
</p>
|
||||
{(
|
||||
[
|
||||
'province_id',
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
portalNavigation,
|
||||
canShowLegacyManagement,
|
||||
currentNavigationGroup,
|
||||
currentNavigationItem,
|
||||
} from '@/lib/portal-navigation';
|
||||
@@ -44,6 +45,7 @@ export function PortalGroupedMenu() {
|
||||
auth: {
|
||||
user: { name?: string; email?: string } | null;
|
||||
canManagePortal: boolean;
|
||||
legacyManagementAccess?:boolean;
|
||||
};
|
||||
existingBusinessEnabled?: boolean;
|
||||
businessReviewUrl?: string | null;
|
||||
@@ -54,6 +56,8 @@ export function PortalGroupedMenu() {
|
||||
profileAccess?: ProfileAccess | null;
|
||||
opportunityManagement?: OpportunityManagementAccess | null;
|
||||
scopedAdministration?: { label: string; href: string }[];
|
||||
contentManagementHref?: string | null;
|
||||
contentManagementAccess?: {opportunities?:{index:string;create?:string|null}};
|
||||
}>();
|
||||
const { t, language, requestCount } = useTempoUi();
|
||||
const mobile = useIsMobile();
|
||||
@@ -63,7 +67,7 @@ export function PortalGroupedMenu() {
|
||||
? { id: navigation.active, ...navigation.view.tools }
|
||||
: undefined,
|
||||
authenticated: !!props.auth?.user,
|
||||
manager: !!props.auth?.canManagePortal,
|
||||
manager: canShowLegacyManagement(props.auth),
|
||||
businesses: props.existingBusinessEnabled,
|
||||
businessReviewUrl: props.businessReviewUrl,
|
||||
education: props.educationEnabled,
|
||||
@@ -72,17 +76,10 @@ export function PortalGroupedMenu() {
|
||||
legacyContent: props.legacyContentEnabled,
|
||||
opportunityManagement: props.opportunityManagement,
|
||||
profileAccess: props.profileAccess,
|
||||
contentManagementHref: props.contentManagementHref,
|
||||
contentManagementAccess: props.contentManagementAccess,
|
||||
scopedAdministration: props.scopedAdministration,
|
||||
});
|
||||
if (props.scopedAdministration?.length)
|
||||
groups.push({
|
||||
id: 'scoped-administration',
|
||||
label: 'Administração',
|
||||
children: props.scopedAdministration.map((item, index) => ({
|
||||
id: `scoped-${index}`,
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
})),
|
||||
});
|
||||
const current = currentNavigationGroup(groups, url);
|
||||
const currentItem = currentNavigationItem(groups, url);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
@@ -134,7 +131,7 @@ export function PortalGroupedMenu() {
|
||||
);
|
||||
const accountGroups = groups.filter(
|
||||
(group) =>
|
||||
!['home', 'discovery', 'realization', 'support'].includes(group.id),
|
||||
!['home', 'discovery', 'realization', 'support','work'].includes(group.id),
|
||||
);
|
||||
const panelLabel = `${t('O meu painel')} — ${requestCount} ${t(requestCount === 1 ? 'pedido recebido' : 'pedidos recebidos')}`;
|
||||
const institutionalCurrent = url.split('?')[0] === '/contacto';
|
||||
@@ -200,6 +197,7 @@ export function PortalGroupedMenu() {
|
||||
'discovery',
|
||||
'realization',
|
||||
'support',
|
||||
'work',
|
||||
].includes(group.id),
|
||||
)
|
||||
.map((group) => (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { ArrowRight, Search } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
@@ -127,7 +128,6 @@ type PortalHeroProps = {
|
||||
query: string;
|
||||
setQuery: (value: string) => void;
|
||||
onSearch: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onNavigate: (view: 'opportunities') => void;
|
||||
stats?: HomeResponse['data']['stats'];
|
||||
};
|
||||
|
||||
@@ -136,7 +136,6 @@ export default function PortalHero({
|
||||
query,
|
||||
setQuery,
|
||||
onSearch,
|
||||
onNavigate,
|
||||
stats,
|
||||
}: PortalHeroProps) {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
@@ -261,20 +260,38 @@ export default function PortalHero({
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => onNavigate('opportunities')}
|
||||
className="inline-flex min-h-11 items-center gap-2 rounded-xl bg-white px-5 py-3 text-sm font-semibold text-[#101820] transition-colors hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white"
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
<Link
|
||||
href="/porque-investir"
|
||||
className="inline-flex min-h-11 items-center rounded-lg border border-white/50 bg-black/15 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white sm:min-h-9"
|
||||
>
|
||||
Consultar oportunidades{' '}
|
||||
Porquê investir em Moçambique?
|
||||
</Link>
|
||||
<Link
|
||||
href="/investimentos/iniciar"
|
||||
className="inline-flex min-h-11 items-center rounded-lg border border-white/50 bg-black/15 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white sm:min-h-9"
|
||||
>
|
||||
Partir de uma ideia
|
||||
</Link>
|
||||
<Link
|
||||
href="/oportunidades"
|
||||
className="inline-flex min-h-11 items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-sm font-semibold text-[#101820] transition-colors hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white sm:min-h-9"
|
||||
>
|
||||
Consultar oportunidades
|
||||
<ArrowRight size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<a
|
||||
</Link>
|
||||
<Link
|
||||
href="/simulador-fiscal"
|
||||
className="inline-flex min-h-11 items-center rounded-xl border border-white/50 bg-black/15 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white"
|
||||
className="inline-flex min-h-11 items-center rounded-lg border border-white/50 bg-black/15 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white sm:min-h-9"
|
||||
>
|
||||
Simulador Fiscal
|
||||
</a>
|
||||
</Link>
|
||||
<Link
|
||||
href="/sectores"
|
||||
className="inline-flex min-h-11 items-center rounded-lg border border-white/50 bg-black/15 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white sm:min-h-9"
|
||||
>
|
||||
Sectores de investimento
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<dl
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {Link} from '@inertiajs/react';
|
||||
import {LayoutGrid} from 'lucide-react';
|
||||
import type {ReactNode} from 'react';
|
||||
import type {PortalNavItem} from '@/lib/portal-navigation';
|
||||
import {currentNavigationItem} from '@/lib/portal-navigation';
|
||||
import {searchTerritoryHref} from '@/lib/search-territory';
|
||||
import {SidebarGroup,SidebarGroupLabel,SidebarMenu,SidebarMenuButton,SidebarMenuItem} from '@/components/ui/sidebar';
|
||||
|
||||
/** Render every authorised destination; a group is never reduced to its first link. */
|
||||
export function PortalSidebarNavigation({groups,url}:{groups:PortalNavItem[];url:string}){
|
||||
const active=currentNavigationItem(groups,url);
|
||||
const ordered=[...groups.filter(group=>group.id==='work'),...groups.filter(group=>group.id!=='work')];
|
||||
const links=(items:PortalNavItem[],depth=0):ReactNode=>items.map(item=>item.children?
|
||||
<li key={item.id} className="list-none"><p className="px-2 pt-3 pb-1 text-xs font-medium text-muted-foreground">{item.label}</p><ul className="ml-2 border-l pl-2">{links(item.children,depth+1)}</ul></li>:
|
||||
item.href?<SidebarMenuItem key={item.id}><SidebarMenuButton asChild isActive={item.id===active} className="h-auto min-h-8 whitespace-normal py-2"><Link href={searchTerritoryHref(item.href,url)} aria-current={item.id===active?'page':undefined}>{depth===0&&<LayoutGrid aria-hidden="true"/>}<span className="min-w-0 flex-1" style={{whiteSpace:'normal',overflow:'visible',textOverflow:'clip',overflowWrap:'anywhere'}}>{item.label}</span></Link></SidebarMenuButton></SidebarMenuItem>:null);
|
||||
return <nav aria-label="Navegação completa do portal">{ordered.map(group=><SidebarGroup key={group.id}><SidebarGroupLabel>{group.label}</SidebarGroupLabel><SidebarMenu>{links(group.children??[])}</SidebarMenu></SidebarGroup>)}</nav>;
|
||||
}
|
||||
@@ -73,6 +73,8 @@ export function PreparationSummary({
|
||||
<h2 className="text-xl font-bold">
|
||||
Preparar o pedido de investimento privado
|
||||
</h2>
|
||||
{caseId&&<p><Link className="inline-block rounded border px-4 py-2 font-semibold" href={`/investimentos/${caseId}/contexto/editar#preparation-questions`}>Preencher ou continuar o formulário</Link></p>}
|
||||
<details><summary className="cursor-pointer font-medium">Consultar situação, documentos e fundamentos</summary>
|
||||
{!data.enabled ? (
|
||||
<p>
|
||||
Use os dados deste dossier para identificar o pedido e os
|
||||
@@ -219,10 +221,11 @@ export function PreparationSummary({
|
||||
className="underline"
|
||||
href={`/investimentos/${caseId}/contexto/editar#preparation-questions`}
|
||||
>
|
||||
Completar apenas os dados em falta ou editar declarações
|
||||
Editar os dados do formulário
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
import { Field, control, button } from '@/pages/management/shared';
|
||||
import {ProactiveSupplyBrowser} from './proactive-supply';
|
||||
|
||||
export type ProactiveEvidence = {id:string;observation_id?:string;category:string;purpose:'local'|'methodology';statement:string;interpretation:string;source:string;locator:string;period:string;scope:string;coverage:string;verified_on:string;access:'internal'|'public';document_id:string};
|
||||
export type ProactiveAnalysis = {
|
||||
version:1;kind:'exists'|'needs'|'ideas';evidence:ProactiveEvidence[];
|
||||
need:{state:'hypothesis'|'confirmed';problem:string;observed_supply:string;observed_demand:string;type:string;clients:string;evidence_ids:string[];limitations:string;missing:string;confirmation:string};
|
||||
idea:{solution:string;activity:string;chain_id:string;stage:string;coverage:string;mode:string;resources:string;assumptions:string;conditions:string;pending:string;next_action:string};
|
||||
relations:{id:string;kind:string;target_id:string;relation:string;reference:string;evidence_ids:string[]}[];
|
||||
supply_comparisons?:{observation_id:string;relationship:'competition'|'complementarity'|'partner'|'uncertain';rationale:string;limitations:string}[];
|
||||
};
|
||||
export type ProactiveOptions = {documents?:{id:string;title:string}[];groups:Record<string,string>;references:Record<string,{id:string;name:string}[]>};
|
||||
export type ProactivePublic = {version?:number;group:string;state:string;need:string;proposal:string;basis:string;limitations:string;next_action:string;evidence_ids:string[];chain_id?:string|null;stage?:string|null;notice?:string;resources?:string;assumptions?:string;conditions?:string;relation_ids?:string[];relations?:{kind:string;target_id:string;label:string;relationship:string;basis:string}[];evidence?:Partial<ProactiveEvidence>[];internal_comparisons?:{relationship:string;rationale:string;limitations:string;statement?:string;source?:string;locator?:string;period?:string}[]};
|
||||
export const proactiveNotice = 'Ausência de registo não comprova ausência de oferta. Proximidade não comprova contrato, participação, energia acessível, terra disponível ou benefício fiscal. Necessidade confirmada não comprova viabilidade comercial; hipótese de fornecimento não é encomenda.';
|
||||
export const newProactive = ():ProactiveAnalysis => ({version:1,kind:'ideas',evidence:[],need:{state:'hypothesis',problem:'',observed_supply:'',observed_demand:'',type:'',clients:'',evidence_ids:[],limitations:'',missing:'',confirmation:''},idea:{solution:'',activity:'',chain_id:'',stage:'',coverage:'',mode:'',resources:'',assumptions:'',conditions:'',pending:'',next_action:''},relations:[]});
|
||||
export const newPublicProactive = ():ProactivePublic => ({group:'ideas',state:'hypothesis',need:'',proposal:'',basis:'',limitations:'',next_action:'',evidence_ids:[],chain_id:'',stage:'',resources:'',assumptions:'',conditions:'',relation_ids:[]});
|
||||
|
||||
export function ProactiveInvestmentEditor({value,onChange,options,history=[],territoryId,recordId}:{value?:ProactiveAnalysis;onChange:(value:ProactiveAnalysis)=>void;options?:ProactiveOptions;history?:{version:number;at:string;state:string;problem:string;confirmation:string}[];territoryId?:string;recordId?:number}){
|
||||
if(!value)return <section className="rounded border p-4"><h2 className="font-semibold">Análise territorial e preparação de ideias</h2><p>Comece pelo território e sector existentes, mesmo sem oportunidade publicada, promotor, montante ou coordenadas. O rascunho e a publicação são independentes.</p><button type="button" className={button} onClick={()=>onChange(newProactive())}>Preparar análise e ideia</button></section>;
|
||||
const patch=(p:Partial<ProactiveAnalysis>)=>onChange({...value,...p});
|
||||
const removeEvidence=(id:string)=>{const proof=value.evidence.find(e=>e.id===id);patch({evidence:value.evidence.filter(e=>e.id!==id),need:{...value.need,evidence_ids:value.need.evidence_ids.filter(x=>x!==id)},relations:value.relations.map(r=>({...r,evidence_ids:r.evidence_ids.filter(x=>x!==id)})),...(proof?.observation_id?{supply_comparisons:(value.supply_comparisons??[]).filter(c=>c.observation_id!==proof.observation_id)}:{})});};
|
||||
const text=(label:string,v:string,change:(v:string)=>void)=><Field label={label}><textarea className={control} aria-label={label} value={v??''} onChange={e=>change(e.target.value)}/></Field>;
|
||||
const select=(label:string,v:string,choices:Record<string,string>,change:(v:string)=>void)=><Field label={label}><select className={control} aria-label={label} value={v??''} onChange={e=>change(e.target.value)}><option value="">Por determinar</option>{Object.entries(choices).map(([key,label])=><option key={key} value={key}>{label}</option>)}</select></Field>;
|
||||
const evidenceChoice=(selected:string[],change:(v:string[])=>void)=><fieldset><legend>Evidências que fundamentam esta entrada</legend>{value.evidence.map((e,i)=><label key={e.id} className="block"><input type="checkbox" checked={selected.includes(e.id)} onChange={event=>change(event.target.checked?[...selected,e.id]:selected.filter(id=>id!==e.id))}/> Evidência {i+1}: {e.statement||'Por descrever'} ({e.access==='public'?'Pública autorizada':'Interna'})</label>)}</fieldset>;
|
||||
return <section className="space-y-4 rounded border p-4" aria-label="Análise territorial e ideia">
|
||||
<h2 className="text-xl font-semibold">Território e sector → evidências → necessidade → ideia</h2><details><summary>Como fundamentar esta análise</summary><p>Inventarie actividades, recursos, infra-estruturas e serviços de apoio. Confronte oferta observada com procura documentada e identifique os clientes ou beneficiários. Registe o período, cobertura e limitações de cada observação. Use referências internacionais para orientar as perguntas; a confirmação do caso depende de evidência local.</p></details><p>Reutiliza o sector e a localização principal escolhidos nesta ficha. Guarde pelo botão de rascunho existente. Nenhum montante, promotor ou ponto exacto é exigido.</p><p>{proactiveNotice}</p>
|
||||
{select('Tipo de conteúdo em preparação',value.kind,{exists:'O que existe',needs:'Necessidade identificada',ideas:'Ideia de investimento'},v=>patch({kind:v as ProactiveAnalysis['kind']}))}
|
||||
<ProactiveSupplyBrowser value={value} onChange={onChange} territoryId={territoryId} recordId={recordId}/>
|
||||
<fieldset className="space-y-3 rounded border p-3"><legend>Evidências e proveniência</legend><p>Registe factos e interpretações em campos separados. Listas de empresas, hotéis, restaurantes, contactos e documentos internos permanecem internos; não são copiados para a divulgação.</p>
|
||||
{value.evidence.map((e,i)=>{const change=(p:Partial<ProactiveEvidence>)=>patch({evidence:value.evidence.map(x=>x.id===e.id?{...x,...p}:x)});if(e.observation_id)return <fieldset key={e.id} className="space-y-2 rounded border p-3"><legend>Evidência interna da oferta {i+1}</legend><p>{e.statement}</p><p>Fonte: {e.source} · {e.locator}</p><p>Período: {e.period} · Abrangência: {e.scope}</p><p>{e.coverage}</p><p>A proveniência é recuperada do original. A data indica consulta documental, sem confirmação de actividade actual.</p>{text(`Interpretação ou hipótese ${i+1}`,e.interpretation,v=>change({interpretation:v}))}<button type="button" onClick={()=>removeEvidence(e.id)}>Retirar evidência {i+1} e a sua comparação deste rascunho</button></fieldset>;return <fieldset key={e.id} className="space-y-2 rounded border p-3"><legend>Evidência {i+1}</legend>
|
||||
{select(`Finalidade da evidência ${i+1}`,e.purpose??'',{local:'Evidência local do caso',methodology:'Referência metodológica — não prova procura local'},v=>change({purpose:v as 'local'|'methodology'}))}
|
||||
{select(`Categoria da evidência ${i+1}`,e.category,{activity:'Actividade',resource:'Recurso',infrastructure:'Infra-estrutura',supply:'Oferta',demand:'Procura'},v=>change({category:v}))}
|
||||
{([['statement','Facto documentado'],['interpretation','Interpretação ou hipótese'],['source','Fonte e exemplar'],['locator','Localizador ou trecho'],['period','Período da evidência'],['scope','Abrangência'],['coverage','Cobertura e limitações']]as const).map(([key,label])=><div key={key}>{text(`${label} ${i+1}`,e[key],v=>change({[key]:v}))}</div>)}
|
||||
<Field label={`Verificação ${i+1}`}><input aria-label={`Verificação ${i+1}`} className={control} type="date" value={e.verified_on??''} onChange={event=>change({verified_on:event.target.value})}/></Field>
|
||||
<Field label={`Documento existente associado ${i+1}`}><select aria-label={`Documento existente associado ${i+1}`} className={control} value={e.document_id??''} onChange={event=>change({document_id:event.target.value})}><option value="">Sem documento associado — indicar fonte e localizador</option>{options?.documents?.map(d=><option key={d.id} value={d.id}>{d.title}</option>)}</select></Field>
|
||||
{select(`Divulgação da evidência ${i+1}`,e.access,{internal:'Interna — análise privada',public:'Pública — origem e divulgação autorizadas'},v=>change({access:v as ProactiveEvidence['access']}))}
|
||||
<button type="button" className="underline" onClick={()=>patch({evidence:value.evidence.filter(x=>x.id!==e.id),need:{...value.need,evidence_ids:value.need.evidence_ids.filter(id=>id!==e.id)},relations:value.relations.map(r=>({...r,evidence_ids:r.evidence_ids.filter(id=>id!==e.id)}))})}>Retirar evidência {i+1} deste rascunho</button>
|
||||
</fieldset>;})}
|
||||
<button type="button" className={button} onClick={()=>patch({evidence:[...value.evidence,{id:createOperationKey(),category:'activity',purpose:'local',statement:'',interpretation:'',source:'',locator:'',period:'',scope:'',coverage:'',verified_on:'',access:'internal',document_id:''}]})}>Acrescentar evidência</button>
|
||||
</fieldset>
|
||||
{value.kind!=='exists'&&<fieldset className="space-y-3 rounded border p-3"><legend>Necessidade ou lacuna</legend>
|
||||
{select('Estado da necessidade',value.need.state,{hypothesis:'Hipótese analítica',confirmed:'Necessidade confirmada — fundamento documentado'},v=>patch({need:{...value.need,state:v as 'hypothesis'|'confirmed'}}))}
|
||||
{select('Tipo de lacuna',value.need.type,{quantity:'Quantidade',quality:'Qualidade',logistics:'Logística',processing:'Transformação',coordination:'Coordenação',support:'Serviços de apoio'},v=>patch({need:{...value.need,type:v}}))}
|
||||
{([['problem','Problema identificado'],['observed_supply','Oferta observada'],['observed_demand','Procura observada'],['clients','Potenciais clientes ou beneficiários'],['limitations','Limitações da necessidade'],['missing','Informação por confirmar'],['confirmation','Fundamentação do estado da necessidade']]as const).map(([key,label])=><div key={key}>{text(label,value.need[key],v=>patch({need:{...value.need,[key]:v}}))}</div>)}
|
||||
{evidenceChoice(value.need.evidence_ids,v=>patch({need:{...value.need,evidence_ids:v}}))}
|
||||
</fieldset>}
|
||||
{value.kind==='ideas'&&<fieldset className="space-y-3 rounded border p-3"><legend>Ideia em preparação</legend>
|
||||
{([['solution','Solução proposta'],['activity','Actividade proposta'],['stage','Etapa da cadeia de valor'],['coverage','Localização ou abrangência da ideia'],['resources','Recursos documentados'],['assumptions','Pressupostos explícitos'],['conditions','Condições da ideia'],['pending','Pendências da ideia'],['next_action','Próxima acção']]as const).map(([key,label])=><div key={key}>{text(label,value.idea[key],v=>patch({idea:{...value.idea,[key]:v}}))}</div>)}
|
||||
{select('Cadeia de valor existente',value.idea.chain_id,Object.fromEntries((options?.references.chain??[]).map(c=>[c.id,c.name])),v=>patch({idea:{...value.idea,chain_id:v}}))}
|
||||
{select('Possibilidade de investimento',value.idea.mode,{create:'Criar actividade',expand:'Expandir actividade',associate:'Associar actividades existentes'},v=>patch({idea:{...value.idea,mode:v}}))}
|
||||
</fieldset>}
|
||||
{value.kind!=='ideas'&&<p>Os campos anteriores da ideia são conservados. Escolha “Ideia de investimento” quando pretender preparar uma solução.</p>}
|
||||
<fieldset className="space-y-3 rounded border p-3"><legend>Relações com estruturas existentes</legend>
|
||||
{value.relations.map((r,i)=>{const change=(p:Partial<typeof r>)=>patch({relations:value.relations.map(x=>x.id===r.id?{...x,...p}:x)});return <fieldset key={r.id} className="rounded border p-3"><legend>Relação {i+1}</legend>
|
||||
{select(`Tipo de referência ${i+1}`,r.kind,{project:'Projecto âncora',cluster:'Cluster',corridor:'Corredor',park:'Parque',chain:'Cadeia de valor'},v=>change({kind:v,target_id:''}))}
|
||||
{select(`Referência existente ${i+1}`,r.target_id,Object.fromEntries((options?.references[r.kind]??[]).map(c=>[c.id,c.name])),v=>change({target_id:v}))}
|
||||
{select(`Fundamento da relação ${i+1}`,r.relation,{documented:'Documentada',hypothesis:'Hipótese de relação',proximity:'Proximidade, sem compromisso'},v=>change({relation:v}))}
|
||||
{text(`Fonte da relação ${i+1}`,r.reference,v=>change({reference:v}))}{evidenceChoice(r.evidence_ids,v=>change({evidence_ids:v}))}
|
||||
<button type="button" className="underline" onClick={()=>patch({relations:value.relations.filter(x=>x.id!==r.id)})}>Retirar relação {i+1}</button>
|
||||
</fieldset>;})}
|
||||
<button type="button" className={button} onClick={()=>patch({relations:[...value.relations,{id:createOperationKey(),kind:'project',target_id:'',relation:'hypothesis',reference:'',evidence_ids:[]}]})}>Relacionar referência existente</button>
|
||||
</fieldset>
|
||||
{!!history.length&&<details><summary>Histórico do fundamento da necessidade</summary>{history.map(h=><p key={h.version}>Versão {h.version} · {h.at} · {h.state==='confirmed'?'Necessidade confirmada':'Hipótese'}: {h.problem} — {h.confirmation||'Fundamentação por completar'}</p>)}</details>}
|
||||
<p>Não calcula mercado, investimento ou rentabilidade. Informação em falta permanece por determinar. Uma nova edição não altera os históricos nem publica a análise.</p>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function ProactiveSummary({value}:{value?:ProactivePublic|null}){
|
||||
if(!value)return null;
|
||||
return <section className="space-y-2 rounded border p-4" aria-label="Origem proactiva do investimento"><h2 className="text-xl font-semibold">{value.group==='ideas'?'Ideia em preparação':value.group==='needs'?'Necessidade identificada':'O que existe'}</h2><p>{value.group==='exists'?'Registo documental; consultar abrangência, cobertura e limitações das fontes.':value.state==='confirmed'?'Necessidade confirmada no âmbito documentado':'Hipótese analítica; conclusão económica por confirmar'}</p>{([['need','Necessidade'],['proposal','Proposta'],['basis','Fundamento'],['limitations','Limitações'],['next_action','Próximo passo'],['stage','Etapa da cadeia'],['resources','Recursos'],['assumptions','Pressupostos'],['conditions','Condições']]as const).map(([key,label])=>value[key]?<p key={key}><strong>{label}:</strong> {value[key]}</p>:null)}{value.internal_comparisons?.map((c,i)=><div key={i} className="rounded border p-3"><strong>Comparação interna da oferta {i+1} · {({competition:'Possível concorrência',complementarity:'Possível complementaridade',partner:'Possível parceiro, sem compromisso',uncertain:'Relação por determinar'} as Record<string,string>)[c.relationship]}</strong><p>{c.statement}</p><p>Fonte: {c.source} · {c.locator} · {c.period}</p><p>{c.rationale}</p><p>Incertezas: {c.limitations}</p></div>)}{value.relations?.map(r=><p key={r.kind+r.target_id}>Relação seleccionada: {r.label} · {r.relationship==='documented'?'Documentada':r.relationship==='proximity'?'Proximidade, sem pertença':'Hipótese de relação'}. Fundamento: {r.basis}. Não confirma contrato, acesso ou participação.</p>)}{value.evidence?.map((e,i)=><p key={e.id??i}>Evidência pública: {e.statement}; fonte: {e.source}; localizador: {e.locator}; período: {e.period}; abrangência: {e.scope}; cobertura: {e.coverage}; verificação: {e.verified_on}.</p>)}<p>{value.notice??proactiveNotice}</p><p>Sem disponibilidade de participação, viabilidade comercial ou benefício fiscal presumidos.</p></section>;
|
||||
}
|
||||
|
||||
export function ProactivePublicationFields({value,onChange,evidence,relations=[],options,reviewed,onReview}:{value:ProactivePublic;onChange:(v:ProactivePublic)=>void;evidence:ProactiveEvidence[];relations?:{id:string;label:string;relationship:string}[];options?:ProactiveOptions;reviewed:boolean;onReview:(v:boolean)=>void}){
|
||||
const text=(key:'need'|'proposal'|'basis'|'limitations'|'next_action'|'stage'|'resources'|'assumptions'|'conditions',label:string)=><Field label={label}><textarea aria-label={label} className={control} value={value[key]??''} onChange={e=>{onChange({...value,[key]:e.target.value});onReview(false);}}/></Field>;
|
||||
return <fieldset className="space-y-3 rounded border p-4"><legend>Síntese proactiva expressamente pública</legend><p>Redija uma síntese independente. Nenhum facto, interpretação, contacto ou fonte interna é copiado automaticamente. A síntese também não pode revelar informação privada indirectamente.</p>
|
||||
<Field label="Grupo público"><select className={control} aria-label="Grupo público" value={value.group} onChange={e=>onChange({...value,group:e.target.value})}>{Object.entries(options?.groups??{exists:'O que existe',needs:'Necessidades identificadas',ideas:'Ideias de investimento'}).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></Field>
|
||||
{value.group!=='exists'&&<Field label="Estado público da necessidade"><select className={control} aria-label="Estado público da necessidade" value={value.state} onChange={e=>onChange({...value,state:e.target.value})}><option value="hypothesis">Hipótese analítica</option><option value="confirmed">Necessidade confirmada com fundamento público</option></select></Field>}
|
||||
{value.group!=='exists'&&text('need','Necessidade pública')}{value.group==='ideas'&&text('proposal','Proposta pública')}{text('basis','Fundamento público')}{text('limitations','Limitações públicas da ideia')}{text('next_action','Próximo passo público')}{text('stage','Etapa pública da cadeia')}{text('resources','Recursos públicos documentados')}{text('assumptions','Pressupostos públicos')}{text('conditions','Condições públicas')}
|
||||
<Field label="Cadeia de valor pública"><select className={control} aria-label="Cadeia de valor pública" value={value.chain_id??''} onChange={e=>onChange({...value,chain_id:e.target.value})}><option value="">Por determinar</option>{options?.references.chain?.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}</select></Field>
|
||||
<fieldset><legend>Relações públicas seleccionadas</legend>{relations.map(r=><label key={r.id} className="block"><input type="checkbox" checked={(value.relation_ids??[]).includes(r.id)} onChange={event=>onChange({...value,relation_ids:event.target.checked?[...(value.relation_ids??[]),r.id]:(value.relation_ids??[]).filter(id=>id!==r.id)})}/> {r.label} · {r.relationship}</label>)}</fieldset><fieldset><legend>Evidências públicas a divulgar</legend>{evidence.map(e=><label key={e.id} className="block"><input type="checkbox" checked={value.evidence_ids.includes(e.id)} onChange={event=>onChange({...value,evidence_ids:event.target.checked?[...value.evidence_ids,e.id]:value.evidence_ids.filter(id=>id!==e.id)})}/> {e.statement||'Evidência pública por descrever'}</label>)}</fieldset>
|
||||
<label className="block"><input type="checkbox" checked={reviewed} onChange={e=>onReview(e.target.checked)}/> Confirmei a divulgação deste texto e das evidências públicas seleccionadas, sem exposição directa ou indirecta de informação privada.</label>
|
||||
</fieldset>;
|
||||
}
|
||||
|
||||
export function proactiveView(raw:unknown):ProactivePublic|null{
|
||||
if(!raw||typeof raw!=='object')return null;
|
||||
const p=raw as Partial<ProactivePublic&ProactiveAnalysis>;
|
||||
if(p.version!==1)return null;
|
||||
if(typeof p.group==='string'&&typeof p.need==='string')return p as ProactivePublic;
|
||||
const a=raw as ProactiveAnalysis;
|
||||
if(!a.need||typeof a.need.problem!=='string'||!a.idea)return null;
|
||||
return {version:1,group:a.kind,state:a.need.state,need:a.kind==='exists'?'':a.need.problem,proposal:a.kind==='ideas'?a.idea.solution:'',basis:a.need.confirmation||'Fundamento por completar',limitations:[a.need.limitations,a.need.missing,a.idea.assumptions,a.idea.conditions,a.idea.pending].filter(Boolean).join('; '),next_action:a.idea.next_action,evidence_ids:a.need.evidence_ids,chain_id:a.idea.chain_id,stage:a.kind==='ideas'?a.idea.stage:'',notice:'Origem privada autorizada deste dossier. '+proactiveNotice,internal_comparisons:(a.supply_comparisons??[]).map(c=>{const e=a.evidence.find(e=>e.observation_id===c.observation_id);return {...c,statement:e?.statement,source:e?.source,locator:e?.locator,period:e?.period};})};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {button,control,Field} from '@/pages/management/shared';
|
||||
import {createOperationKey} from '@/lib/operation-key';
|
||||
import type {ProactiveAnalysis} from './proactive-investment';
|
||||
|
||||
type Observation={id:string;name:string;province:string;district:string;territory_id:string;cae:string;activity:string;sector:string;source_date:string;sheet:string;row_number:number;source_hash:string;source_name:string;source_period:string;identity_conflict:boolean};
|
||||
type Result={installed:boolean;rows:Observation[];total:number|null;duplicate_sightings?:number;page?:number;per_page?:number;sectors?:string[];territory_coverage?:{unmapped:number;province_only:number};limitations:string;status:string};
|
||||
export function ProactiveSupplyBrowser({value,onChange,territoryId,recordId}:{value:ProactiveAnalysis;onChange:(v:ProactiveAnalysis)=>void;territoryId?:string;recordId?:number}){
|
||||
const [activity,setActivity]=useState('');const [sector,setSector]=useState('');
|
||||
const [result,setResult]=useState<Result|null>(null);const [busy,setBusy]=useState(false);const [error,setError]=useState('');
|
||||
const [query,setQuery]=useState<{territory:string;activity:string;sector:string;page:number}|null>(null);
|
||||
useEffect(()=>{setResult(null);setQuery(null);setSector('');setError('');setBusy(false);},[territoryId]);
|
||||
useEffect(()=>{
|
||||
if(!query)return;const controller=new AbortController();setBusy(true);setError('');
|
||||
const p=new URLSearchParams({territory_id:query.territory,activity:query.activity,sector:query.sector,page:String(query.page)});if(recordId)p.set('record_id',String(recordId));
|
||||
fetch('/minhas-oportunidades/oferta-interna?'+p,{headers:{Accept:'application/json'},credentials:'same-origin',signal:controller.signal})
|
||||
.then(async r=>{if(!r.ok)throw new Error(r.status===403?'Consulta interna fora do âmbito de acesso desta sessão.':'Não foi possível consultar a base interna. A oferta permanece por determinar.');return r.json() as Promise<Result>;})
|
||||
.then(setResult).catch(e=>{if(e.name!=='AbortError'){setResult(null);setError(e.message);}}).finally(()=>{if(!controller.signal.aborted)setBusy(false);});
|
||||
return ()=>controller.abort();
|
||||
},[query,recordId]);
|
||||
const selected=value.supply_comparisons??[];
|
||||
const compare=(row:Observation)=>{
|
||||
if(selected.some(c=>c.observation_id===row.id))return;
|
||||
const proof=value.evidence.find(e=>e.observation_id===row.id);
|
||||
onChange({...value,supply_comparisons:[...selected,{observation_id:row.id,relationship:'uncertain',rationale:'',limitations:'Actividade actual, capacidade, procura e disponibilidade para parceria por confirmar.'}],evidence:proof?value.evidence:[...value.evidence,{
|
||||
id:createOperationKey(),observation_id:row.id,category:'supply',purpose:'local',access:'internal',document_id:'',
|
||||
statement:`Registo administrativo: ${row.name}; CAE ${row.cae}; ${row.activity||'actividade por classificar'}; ${row.district}, ${row.province}. Não confirma actividade actual.`,
|
||||
interpretation:'',source:`${row.source_name}; SHA256 ${row.source_hash}`,locator:`${row.sheet}!linha ${row.row_number}`,
|
||||
period:`${row.source_date}; ${row.source_period}`,scope:`${row.district}, ${row.province}`,
|
||||
coverage:result?.limitations??'Cobertura incompleta; situação actual por confirmar.',verified_on:new Date().toISOString().slice(0,10),
|
||||
}]});
|
||||
};
|
||||
return <fieldset className="space-y-3 rounded border p-3"><legend>Consultar oferta existente — base interna</legend>
|
||||
<p>Consulta a localização escolhida nesta ficha e os seus territórios subordinados. Os filtros por sector e actividade CAE não limitam a análise ao sector da ideia: podem existir concorrentes, complementos e parceiros noutros sectores. Nenhuma empresa é publicada.</p>
|
||||
<Field label="Sector da oferta (classificação CAE)"><select className={control} aria-label="Sector da oferta (classificação CAE)" value={sector} onChange={e=>setSector(e.target.value)}><option value="">Todos os sectores registados</option>{result?.sectors?.map(s=><option key={s}>{s}</option>)}</select></Field>
|
||||
<Field label="Actividade ou código CAE da oferta"><input className={control} aria-label="Actividade ou código CAE da oferta" value={activity} onChange={e=>setActivity(e.target.value)}/></Field>
|
||||
<button type="button" className={button} disabled={!territoryId||busy} onClick={()=>setQuery({territory:territoryId!,activity,sector,page:1})}>{busy?'A consultar…':'Consultar oferta da localização'}</button>
|
||||
{!territoryId&&<p>Escolha a localização para consultar. Sem localização, a oferta permanece por determinar.</p>}
|
||||
{error&&<p role="alert">{error}</p>}
|
||||
{result&&<><p role="status">{result.status}</p><p>{result.limitations}</p>{result.territory_coverage&&<p>Cobertura territorial da base nacional: {result.territory_coverage.unmapped} observações sem correspondência territorial única; {result.territory_coverage.province_only} apenas com província. Estas últimas não comprovam oferta num distrito específico.</p>}{result.total!==null&&<p>{result.total} observações distintas; {result.duplicate_sightings??0} repetições dos mesmos campos administrativos conservadas na origem. Não é uma contagem de empresas únicas.</p>}
|
||||
{result.rows.map(row=><article key={row.id} className="rounded border p-3"><h3 className="font-semibold">{row.name}</h3><p>{row.district}, {row.province} · {row.sector||'Sector por classificar'} · CAE {row.cae||'não indicado'} · {row.activity||'Actividade por classificar'}</p><p>Fonte: {row.source_name} · {row.sheet}, linha {row.row_number} · {row.source_date} · {row.source_period}</p><details><summary>Rastreabilidade do exemplar</summary><p>SHA256: {row.source_hash}</p><p>Observação: {row.id}</p></details>{row.identity_conflict&&<p>Identificador partilhado por denominações divergentes; identidade por conferir. Os registos não foram fundidos.</p>}<button type="button" className={button} disabled={busy||selected.some(c=>c.observation_id===row.id)||selected.length>=40||value.evidence.length>=60} onClick={()=>compare(row)}>Comparar com a ideia</button></article>)}
|
||||
{query&&<div className="flex gap-3"><button type="button" disabled={busy||query.page<=1} onClick={()=>setQuery({...query,page:query.page-1})}>Página anterior</button><span>Página {result.page??1}</span><button type="button" disabled={busy||query.page*25>=(result.total??0)} onClick={()=>setQuery({...query,page:query.page+1})}>Página seguinte</button></div>}
|
||||
</>}
|
||||
{selected.map((c,i)=>{const proof=value.evidence.find(e=>e.observation_id===c.observation_id);const patch=(p:Partial<typeof c>)=>onChange({...value,supply_comparisons:selected.map(x=>x.observation_id===c.observation_id?{...x,...p}:x)});return <fieldset className="space-y-2 rounded border p-3" key={c.observation_id}><legend>Comparação {i+1}</legend><p>{proof?.statement??'Evidência retirada: volte a associar a observação antes de guardar.'}</p><p>{proof?.source} · {proof?.locator}</p><p>Ideia em preparação: {value.idea.solution||'Descreva a ideia no campo existente abaixo.'}</p><Field label={`Relação com a ideia ${i+1}`}><select className={control} value={c.relationship} onChange={e=>patch({relationship:e.target.value as typeof c.relationship})}><option value="uncertain">Relação por determinar</option><option value="competition">Possível concorrência</option><option value="complementarity">Possível complementaridade</option><option value="partner">Possível parceiro — sem compromisso</option></select></Field><Field label={`Fundamento da comparação ${i+1}`}><textarea className={control} value={c.rationale} onChange={e=>patch({rationale:e.target.value})}/></Field><Field label={`Incertezas da comparação ${i+1}`}><textarea className={control} value={c.limitations} onChange={e=>patch({limitations:e.target.value})}/></Field><button type="button" onClick={()=>onChange({...value,supply_comparisons:selected.filter(x=>x.observation_id!==c.observation_id)})}>Retirar comparação {i+1}</button></fieldset>;})}
|
||||
{!!selected.length&&<p>Use estas comparações para fundamentar a necessidade abaixo e seleccione as evidências correspondentes. A fonte administrativa não confirma procura nem insuficiência de oferta. Guarde no rascunho existente; a continuidade para o dossier conserva o percurso já entregue.</p>}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export function profileLinks(access: ProfileAccess | null | undefined) {
|
||||
...(access.administration
|
||||
? [
|
||||
{
|
||||
title: 'Instalação dos perfis 040000',
|
||||
title: 'Configuração dos perfis',
|
||||
href: '/management/profile-install',
|
||||
},
|
||||
]
|
||||
@@ -41,16 +41,26 @@ export default function ProfileNavigation() {
|
||||
profileAccess?: ProfileAccess | null;
|
||||
}>().props;
|
||||
if (!profileAccess) return null;
|
||||
const links = profileLinks(profileAccess);
|
||||
const workHrefs = [profileAccess.mine, profileAccess.create, profileAccess.review].filter(Boolean);
|
||||
const work = links.filter((link) => workHrefs.includes(link.href));
|
||||
const access = links.filter((link) => !workHrefs.includes(link.href));
|
||||
return (
|
||||
<nav
|
||||
aria-label="Perfil e oportunidades"
|
||||
className="flex flex-wrap gap-4 rounded border p-4"
|
||||
>
|
||||
{profileLinks(profileAccess).map((link) => (
|
||||
{work.map((link) => (
|
||||
<Link key={link.href} href={link.href} className="underline">
|
||||
{link.title}
|
||||
</Link>
|
||||
))}
|
||||
{access.length > 0 && <details className="basis-full">
|
||||
<summary className="cursor-pointer font-medium">O meu acesso e administração</summary>
|
||||
<div className="mt-3 flex flex-wrap gap-4">{access.map((link) =>
|
||||
<Link key={link.href} href={link.href} className="underline">{link.title}</Link>
|
||||
)}</div>
|
||||
</details>}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function ProfileProofPicker({documents,value,onChange,disabled=false}:{
|
||||
documents:{id:string;file_name:string}[];value:string[];onChange:(ids:string[])=>void;disabled?:boolean;
|
||||
}) {
|
||||
const [query,setQuery]=useState('');
|
||||
const [showAll,setShowAll]=useState(false);
|
||||
const [limit,setLimit]=useState(10);
|
||||
const normalize=(text:string)=>text.normalize('NFD').replace(/\p{Diacritic}/gu,'').toLocaleLowerCase();
|
||||
const matches=documents.filter(document=>normalize(document.file_name).includes(normalize(query)));
|
||||
const toggle=(id:string)=>onChange(value.includes(id)?value.filter(item=>item!==id):[...value,id]);
|
||||
return <fieldset disabled={disabled} className="space-y-3 rounded border p-4">
|
||||
<legend>Escolher comprovativos</legend>
|
||||
<p className="text-sm">Escolha os seus documentos que sustentam este pedido. A lista contém ficheiros próprios com análise antimalware concluída; não confirma a sua adequação como comprovativo.</p>
|
||||
<p role="status">{value.length} comprovativos escolhidos</p>
|
||||
{value.map(id=><div key={id} className="flex items-start justify-between gap-3 rounded border p-2">
|
||||
<span className="min-w-0 break-words">{documents.find(document=>document.id===id)?.file_name??'Ficheiro seleccionado indisponível nesta consulta'}</span>
|
||||
<button type="button" className="underline" onClick={()=>toggle(id)}>Retirar</button>
|
||||
</div>)}
|
||||
<label className="block">Pesquisar nos meus ficheiros
|
||||
<input type="search" className="mt-1 w-full rounded border p-2" value={query} onChange={event=>{setQuery(event.target.value);setLimit(10);}} />
|
||||
</label>
|
||||
<button type="button" className="underline" onClick={()=>setShowAll(!showAll)}>{showAll?'Fechar lista':'Ver ficheiros disponíveis'}</button>
|
||||
{(query||showAll)&&<div className="space-y-2">
|
||||
<p role="status">{matches.length} ficheiros encontrados · a mostrar até {Math.min(limit,matches.length)}</p>
|
||||
{matches.slice(0,limit).map(document=><label key={document.id} className="flex gap-2 rounded border p-2">
|
||||
<input type="checkbox" checked={value.includes(document.id)} onChange={()=>toggle(document.id)} />
|
||||
<span className="min-w-0 break-words">{document.file_name}</span>
|
||||
</label>)}
|
||||
{matches.length>limit&&<button type="button" className="underline" onClick={()=>setLimit(limit+10)}>Mostrar mais ficheiros</button>}
|
||||
</div>}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ function count(props: Record<string, unknown>): number | null {
|
||||
}
|
||||
export default function SearchFeedback() {
|
||||
const page = usePage();
|
||||
const handledLocally = page.component === 'investment/catalogue';
|
||||
const handledLocally = ['investment/catalogue', 'investment/zones'].includes(page.component);
|
||||
const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle');
|
||||
const latest = useRef<{
|
||||
url: string;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import SectorEntry, { type SectorEntryData } from '@/components/sector-entry';
|
||||
|
||||
export type SectorDetailData = {
|
||||
sector: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
short_description: string | null;
|
||||
};
|
||||
sectorEntry?: SectorEntryData | null;
|
||||
opportunities?: {
|
||||
title: string;
|
||||
slug: string;
|
||||
province: string | null;
|
||||
summary: string | null;
|
||||
}[];
|
||||
};
|
||||
const button =
|
||||
'inline-block rounded-lg bg-emerald-800 px-5 py-3 font-semibold text-white disabled:opacity-50';
|
||||
export default function SectorContent({
|
||||
sector,
|
||||
sectorEntry,
|
||||
opportunities,
|
||||
headingLevel = 2,
|
||||
}: SectorDetailData & { headingLevel?: 2 | 3 }) {
|
||||
const Heading = headingLevel === 2 ? 'h2' : 'h3';
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="whitespace-pre-line">
|
||||
{sector.description ||
|
||||
sector.short_description ||
|
||||
'Descrição sectorial em preparação.'}
|
||||
</p>
|
||||
{!sectorEntry && (
|
||||
<Link
|
||||
className={button}
|
||||
href={'/investimentos/iniciar?source=sector:' + sector.id}
|
||||
>
|
||||
Iniciar ou continuar uma ideia neste sector
|
||||
</Link>
|
||||
)}
|
||||
{sectorEntry && (
|
||||
<SectorEntry
|
||||
headingLevel={headingLevel}
|
||||
data={{
|
||||
...sectorEntry,
|
||||
start_href: sectorEntry.start_href
|
||||
? '/investimentos/iniciar?source=sector:' +
|
||||
sector.id
|
||||
: null,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Heading className="text-xl font-bold">
|
||||
Oportunidades no sector
|
||||
</Heading>
|
||||
{opportunities?.map((o) => (
|
||||
<article key={o.slug} className="rounded border bg-white p-5">
|
||||
<Link
|
||||
className="text-lg font-bold underline"
|
||||
href={`/oportunidades/${o.slug}`}
|
||||
>
|
||||
{o.title}
|
||||
</Link>
|
||||
<p>{o.province}</p>
|
||||
<p>{o.summary}</p>
|
||||
<ul aria-label="Localizações associadas">
|
||||
{sectorEntry?.locations
|
||||
.filter(
|
||||
(l) => l.href === `/oportunidades/${o.slug}`,
|
||||
)
|
||||
.filter(
|
||||
(l, i, rows) =>
|
||||
rows.findIndex(
|
||||
(other) =>
|
||||
JSON.stringify(other.location) ===
|
||||
JSON.stringify(l.location),
|
||||
) === i,
|
||||
)
|
||||
.map((l, i) => (
|
||||
<li key={i}>
|
||||
{typeof l.location.name === 'string'
|
||||
? l.location.name
|
||||
: 'Consultar localização e fonte na oportunidade'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
{!opportunities?.length && (
|
||||
<p>Sem oportunidades publicadas neste sector.</p>
|
||||
)}
|
||||
<Link
|
||||
className={button}
|
||||
href={`/oportunidades?sector=${sector.slug}`}
|
||||
>
|
||||
Pesquisar todas as oportunidades do sector
|
||||
</Link>
|
||||
<p>
|
||||
<Link href="/como-investir" className="underline">
|
||||
Preparar o investimento
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link href="/apoio" className="underline">
|
||||
Pedir apoio sectorial
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,12 +19,20 @@ export type SectorEntryData = {
|
||||
}[];
|
||||
start_href: string | null;
|
||||
register_href: string | null;
|
||||
register_label?: string;
|
||||
start_notice: string | null;
|
||||
register_notice: string | null;
|
||||
specialization_href: string | null;
|
||||
content_notice: string | null;
|
||||
};
|
||||
export default function SectorEntry({ data }: { data: SectorEntryData }) {
|
||||
export default function SectorEntry({
|
||||
data,
|
||||
headingLevel = 2,
|
||||
}: {
|
||||
data: SectorEntryData;
|
||||
headingLevel?: 2 | 3;
|
||||
}) {
|
||||
const Heading = headingLevel === 2 ? 'h2' : 'h3';
|
||||
return (
|
||||
<section
|
||||
className="space-y-5"
|
||||
@@ -51,7 +59,7 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) {
|
||||
className="rounded border px-4 py-3"
|
||||
href={data.register_href}
|
||||
>
|
||||
Cadastrar oportunidade — escolher modalidade
|
||||
{data.register_label ?? 'Cadastrar oportunidade — escolher modalidade'}
|
||||
</Link>
|
||||
) : (
|
||||
<p role="status">{data.register_notice}</p>
|
||||
@@ -64,7 +72,9 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) {
|
||||
perfil e a modalidade já autorizados; se ainda não tem perfil,
|
||||
pode preparar a ideia e pedir o perfil de promotor.
|
||||
</p>
|
||||
<h2 className="text-xl font-bold">Descubra onde investir</h2>
|
||||
<Heading className="text-xl font-bold">
|
||||
Descubra onde investir
|
||||
</Heading>
|
||||
{data.content_notice && <p>{data.content_notice}</p>}
|
||||
{!data.articles.length && (
|
||||
<p>
|
||||
@@ -74,7 +84,11 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) {
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{data.articles.map((a) => (
|
||||
<EducationalArticleCard key={a.href} article={a} />
|
||||
<EducationalArticleCard
|
||||
key={a.href}
|
||||
article={a}
|
||||
headingLevel={headingLevel === 2 ? 3 : 4}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{data.specialization_href && (
|
||||
@@ -87,9 +101,9 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) {
|
||||
dependências.
|
||||
</p>
|
||||
)}
|
||||
<h2 className="text-xl font-bold">
|
||||
<Heading className="text-xl font-bold">
|
||||
Projetos âncora, clusters e território
|
||||
</h2>
|
||||
</Heading>
|
||||
{data.relations.length ? (
|
||||
<ul>
|
||||
{data.relations.map((r) => (
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {SimulationIrpcAnnual} from '@/components/simulation-irpc-annual';
|
||||
import {SimulationIrpcNormal} from '@/components/simulation-irpc-normal';
|
||||
import {SimulationIrpcWithholdings} from '@/components/simulation-irpc-withholdings';
|
||||
import {SimulationIspcResiduals} from '@/components/simulation-ispc-residuals';
|
||||
import {SimulationIspcWithholding} from '@/components/simulation-ispc-withholding';
|
||||
import {SimulationIspcQuarter} from '@/components/simulation-ispc-quarter';
|
||||
import {SimulationIspcEligibility} from './simulation-ispc-eligibility';
|
||||
@@ -3507,6 +3511,10 @@ export function SimulationForm() {
|
||||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIspcEligibility draft={draft} onChange={change} />}
|
||||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIspcQuarter draft={draft} onChange={change} />}
|
||||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIspcWithholding draft={draft} onChange={change} />}
|
||||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIspcResiduals draft={draft} onChange={change} />}
|
||||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIrpcWithholdings draft={draft} onChange={change} />}
|
||||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIrpcNormal draft={draft} onChange={change} />}
|
||||
{(current.id === 'financial' || current.id === 'tax') && <SimulationIrpcAnnual draft={draft} onChange={change} />}
|
||||
{(current.id === 'financial' || current.id === 'tax') && <SimulationMonthlyIva draft={draft} busy={isCalculating} onCalculate={runSimulation} onChange={change} />}
|
||||
{current.id === 'financial' &&
|
||||
(draft.detail_level === 'advanced' ||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {SimulationIrpcEconomic} from './simulation-irpc-economic';
|
||||
import {FiscalProofParcelFields} from './fiscal-proof-parcel';
|
||||
import {SimulationIrpcCarry} from './simulation-irpc-carry';
|
||||
import {SimulationIrpcInternational} from './simulation-irpc-international';
|
||||
import {SimulationIrpcPec} from './simulation-irpc-pec';
|
||||
import {useState} from 'react';
|
||||
import {annualBook,newAnnualBook,saveAnnualBook,reuseAnnualAssessment,annualAssessment,annualWithholdingOptions,reuseAnnualWithholding,newAnnualPpc,recordAnnual,annualReportRows,annualHtml,annualSource,type AnnualBook,type AnnualPpc} from '@/lib/irpc-annual-settlement';
|
||||
|
||||
type Props={draft:Record<string,unknown>;onChange:(field:string,value:string)=>void};
|
||||
export function SimulationIrpcAnnual({draft,onChange}:Props){
|
||||
const[error,setError]=useState('');if(draft.fiscal_component!=='irpc')return null;
|
||||
try{
|
||||
const b=annualBook(draft),save=(next:AnnualBook)=>{try{onChange('modelo_a_snapshot',saveAnnualBook(draft,next));setError('');}catch(e){setError(String(e));}};
|
||||
if(!b)return<section aria-label="IRPC — L8 fecho anual" className="rounded border p-4"><h4>L8 — da colecta ao saldo anual simulado</h4><p>Reutiliza a colecta válida, retenções efectivamente sofridas por conta e PPC comprovados do próprio sujeito/exercício. PEC comprovado pode ser detalhado no próprio exercício; créditos com regras próprias permanecem separados.</p><button type="button" onClick={()=>{try{save(reuseAnnualAssessment(draft,newAnnualBook()));}catch(e){setError(String(e));}}}>Preparar saldo anual L8</button>{error&&<p role="alert">{error}</p>}</section>;
|
||||
const patch=(p:Partial<AnnualBook>)=>save({...b,...p}),a=annualAssessment(draft),options=annualWithholdingOptions(draft);
|
||||
const field=(key:'subjectNuit'|'year'|'reference'|'assessmentReference',label:string)=><label className="block">{label}<input aria-label={label} value={b[key]} onChange={e=>patch({[key]:e.target.value})}/></label>;
|
||||
const group=(key:'withholdings'|'ppc',label:string)=><><label className="block">Estado — {label}<select aria-label={'Estado L8 — '+label} value={b[key].state} onChange={e=>patch({[key]:{...b[key],state:e.target.value as ''|'known'|'absent'}})}><option value="">Informação em falta</option><option value="known">Valores conhecidos — seleccionar comprovativos</option><option value="absent">Ausência declarada com suporte</option></select></label><label className="block">Suporte — {label}<input aria-label={'Suporte L8 — '+label} value={b[key].reference} onChange={e=>patch({[key]:{...b[key],reference:e.target.value}})}/></label></>;
|
||||
return<section aria-label="IRPC — L8 fecho anual" className="space-y-3 rounded border p-4"><h4>L8 — colecta, abatimentos e saldo anual simulado</h4><p>A colecta acima é anterior aos abatimentos. Este saldo tem cálculo e histórico próprios; retenção entregue pela empresa por pagamento a terceiros não é retenção sofrida. O resultado continua simulado, sem submissão, pagamento ou reembolso automático.</p><p>{annualSource(b)}</p>{error&&<p role="alert">{error}</p>}
|
||||
{field('subjectNuit','NUIT do sujeito do fecho L8')}{field('year','Exercício do fecho L8')}{field('reference','Suporte do fecho da entidade e exercício L8')}
|
||||
<p>Colecta disponível:{a.colecta?a.colecta.numerator+'/'+a.colecta.denominator+' MZN':'Indeterminada'};origem:{a.kind==='special'?'L7 qualificado':'L6 normal'};NUIT:{a.subjectNuit||'Por identificar'};exercício:{a.year||'Por identificar'}.</p><button type="button" onClick={()=>{try{save(reuseAnnualAssessment(draft,b));}catch(e){setError(String(e));}}}>Reutilizar colecta corrente L6/L7</button>{field('assessmentReference','Suporte da colecta reutilizada L8')}
|
||||
<fieldset className="space-y-2 rounded border p-3"><legend>Retenções efectivamente sofridas por conta</legend>{group('withholdings','Retenções sofridas')}
|
||||
{options.length===0?<p>Não há afectações de comprovativosL5 neste contexto. Reutilize e reconcilie a retenção recebida emL5 ou declare ausência com suporte.</p>:options.map(o=><div key={o.rowId+'|'+o.paymentId} className="rounded border p-2"><p>{o.document||'Prova por identificar'};emitente:{o.issuer};data:{o.date};parcelaL5:{o.amount}MZN;{o.reason||'Retenção recebida/efectiva por conta reconciliada emL5.'}</p><button type="button" disabled={!o.ready||b.withholdings.rows.some(r=>r.rowId===o.rowId&&r.paymentId===o.paymentId)} onClick={()=>{try{save(reuseAnnualWithholding(draft,b,o.rowId,o.paymentId,crypto.randomUUID()));}catch(e){setError(String(e));}}}>Seleccionar prova L5 {o.document||o.paymentId}</button></div>)}
|
||||
{b.withholdings.rows.map((r,index)=>{const change=(p:Partial<typeof r>)=>patch({withholdings:{...b.withholdings,rows:b.withholdings.rows.map(x=>x.id===r.id?{...x,...p}:x)}});return<fieldset key={r.id} className="rounded border p-2"><legend>Retenção seleccionada {index+1}</legend><p>Rendimento:{r.rowId};prova:{r.paymentId};NUIT:{r.subjectNuit};exercício:{r.year}. Fonte conservada. Se mudou, retire esta selecção e seleccione a prova actual. O histórico mantém-se.</p><label className="block">Parcela a abater<input aria-label={'Parcela da retenção L8 '+(index+1)} value={r.amount} onChange={e=>change({amount:e.target.value})}/></label><label className="block">O próprio sujeito é beneficiário e o rendimento está incluído na base deste exercício<select aria-label={'Rendimento incluído L8 '+(index+1)} value={r.incomeIncluded} onChange={e=>change({incomeIncluded:e.target.value as ''|'yes'|'no'})}><option value="">Informação em falta</option><option value="yes">Sim — documentado</option><option value="no">Não — crédito não aplicável neste fecho</option></select></label><label className="block">Suporte da titularidade e inclusão<input aria-label={'Suporte da retenção L8 '+(index+1)} value={r.reference} onChange={e=>change({reference:e.target.value})}/></label><FiscalProofParcelFields value={r} change={change} label={"Retenção L8 "+(index+1)}/><button type="button" onClick={()=>patch({withholdings:{...b.withholdings,rows:b.withholdings.rows.filter(x=>x.id!==r.id)}})}>Retirar selecção L8 da retenção {index+1}</button></fieldset>;})}</fieldset>
|
||||
<fieldset className="space-y-2 rounded border p-3"><legend>PPC efectivamente pagos</legend>{group('ppc','PPC efectuados')}<p>Regista comprovativos de pagamentos por conta; não calcula automaticamente a obrigação PPC nem usa pagamentos previstos no plano.</p><button type="button" onClick={()=>patch({ppc:{...b.ppc,rows:[...b.ppc.rows,{...newAnnualPpc(crypto.randomUUID()),subjectNuit:b.subjectNuit,year:b.year}]}})}>Registar comprovativo PPC</button>
|
||||
{b.ppc.rows.map((r,index)=>{const change=(p:Partial<AnnualPpc>)=>patch({ppc:{...b.ppc,rows:b.ppc.rows.map(x=>x.id===r.id?{...x,...p}:x)}});return<fieldset key={r.id} className="rounded border p-2"><legend>PPC {index+1}</legend>{([['document','Comprovativo'],['issuer','Emitente'],['date','Data (AAAA-MM-DD)'],['subjectNuit','NUIT beneficiário'],['year','Exercício do comprovativo'],['currency','Moeda do comprovativo'],['proofAmount','Montante comprovado'],['amount','Parcela a abater'],['reference','Suporte do pagamento e afectação']]as const).map(([key,label])=><label key={key} className="block">{label}<input aria-label={'PPC L8 '+(index+1)+' — '+label} value={r[key]} onChange={e=>change({[key]:e.target.value})}/></label>)}<label className="block">Pagamento efectivamente comprovado<select aria-label={'PPC L8 '+(index+1)+' — Efectivo'} value={r.effective} onChange={e=>change({effective:e.target.value as ''|'yes'|'no'})}><option value="">Informação em falta</option><option value="yes">Sim — comprovativo reconciliado</option><option value="no">Não — apenas previsão/outro estado</option></select></label><FiscalProofParcelFields value={r} change={change} label={"PPC L8 "+(index+1)}/><button type="button" onClick={()=>patch({ppc:{...b.ppc,rows:b.ppc.rows.filter(x=>x.id!==r.id)}})}>Retirar PPC {index+1}</button></fieldset>;})}</fieldset>
|
||||
<SimulationIrpcEconomic book={b} save={save} residentConfirmed={a.current&&a.book.scope==='resident_general'&&b.subjectNuit===a.subjectNuit&&b.year===a.year&&b.assessmentKey===a.key}/><SimulationIrpcInternational book={b} save={save}/><SimulationIrpcPec book={b} save={save}/><SimulationIrpcCarry book={b} save={save}/>{([...(b.pecPayments?[]:[['pec','PEC']as const]),...(b.internationalCredits?[]:[['credits','Créditos com regras próprias']as const])]).map(([key,label])=><fieldset key={key} className="rounded border p-3"><legend>{label} — tratamento separado</legend><label className="block">Estado — {label}<select aria-label={'Estado L8 — '+label} value={b[key].state} onChange={e=>patch({[key]:{...b[key],state:e.target.value as ''|'known'|'absent'}})}><option value="">Informação em falta</option><option value="absent">Ausência declarada com suporte</option><option value="known">Valor conhecido — positivo exige regra própria</option></select></label>{(b[key].state==='known'||b[key].amount)&&<label className="block">Montante declarado<input aria-label={'Montante L8 — '+label} value={b[key].amount} onChange={e=>patch({[key]:{...b[key],amount:e.target.value}})}/></label>}<label className="block">Suporte<input aria-label={'Suporte L8 — '+label} value={b[key].reference} onChange={e=>patch({[key]:{...b[key],reference:e.target.value}})}/></label></fieldset>)}
|
||||
<button type="button" onClick={()=>{try{save(recordAnnual(draft,b));}catch(e){setError(String(e));}}}>Calcular e guardar saldo anual L8</button><button type="button" onClick={()=>{try{const html=annualHtml(draft),url=URL.createObjectURL(new Blob([html],{type:'text/html;charset=utf-8'})),link=document.createElement('a');link.href=url;link.download='IRPC-L8-saldo-simulado.html';link.click();URL.revokeObjectURL(url);setError('');}catch(e){setError(String(e));}}}>Exportar saldo anual L8</button>
|
||||
{annualReportRows(draft).map((r,n)=><p key={n}>{r[1]} — {r[2]} — {r[3]}</p>)}</section>;
|
||||
}catch(e){return<p role="alert">PreparaçãoL8 não recuperável:{String(e)}. Originais e históricos conservados.</p>;}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {newNormalAssessmentInput,type NormalAssessmentInput} from '@/lib/irpc-normal-assessment';
|
||||
import type {NormalBook} from '@/lib/irpc-normal';
|
||||
|
||||
export function SimulationIrpcAssessment({book,save}:{book:NormalBook;save:(b:NormalBook)=>void}){
|
||||
if(!book.base)return null;
|
||||
const a=book.assessment,change=(p:Partial<NormalAssessmentInput>)=>save({...book,assessment:{...(a??newNormalAssessmentInput()),...p}});
|
||||
if(!a)return<button type="button" onClick={()=>change({})}>Iniciar cálculo da colecta normal</button>;
|
||||
return<fieldset aria-label="Colecta — regime normal" className="space-y-3 rounded border p-3"><legend>Matéria colectável → colecta</legend><p>Reutiliza a matéria colectável actual. Exercício:{book.year||'por identificar'};período anual correspondente à conferência financeira. A taxa geral identificada para2026 é32%,artigo61(1). Benefícios,taxas reduzidas,tributações autónomas e outros casos especiais permanecem separados.</p>
|
||||
<label className="block">Enquadramento da colecta<select aria-label="Enquadramento da colecta" value={a.treatment} onChange={e=>change({treatment:e.target.value as NormalAssessmentInput['treatment']})}><option value="">Informação em falta</option><option value="general_61_1">Regime normal — taxa geral32%,artigo61(1)</option><option value="other">Benefício ou caso especial — correspondência própria</option></select></label>
|
||||
<label className="block">Suporte ou pressuposto da taxa geral neste caso<input value={a.reference} onChange={e=>change({reference:e.target.value})}/></label>
|
||||
<p>Lucro tributável → matéria colectável → colecta → saldo final a pagar ou recuperar. O saldo final pertence aL8:retenções,pagamentos e créditos anuais não são abatidos aqui. Uma colecta conhecida não significa saldo final conhecido.</p>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {prepareCreditCarry, type AnnualBook} from '@/lib/irpc-annual-settlement';
|
||||
import {carrySource, newCarryLot, newCarryUse, type CarryLot, type CreditCarry} from '@/lib/irpc-credit-carry';
|
||||
import CountrySelect, {type Country} from '@/components/country-select';
|
||||
import {countryIsoCodes} from '@/lib/country-iso-codes';
|
||||
|
||||
const pt = new Intl.DisplayNames(['pt'], {type: 'region'});
|
||||
const en = new Intl.DisplayNames(['en'], {type: 'region'});
|
||||
const countries: Country[] = countryIsoCodes.filter(code => code !== 'MZ').map(code => ({code, name: pt.of(code) ?? code, name_en: en.of(code) ?? code})).sort((a, b) => a.name.localeCompare(b.name, 'pt'));
|
||||
|
||||
export function SimulationIrpcCarry({book, save}: {book: AnnualBook; save: (book: AnnualBook) => void}) {
|
||||
const c = book.creditCarry;
|
||||
if (!c) return <fieldset className="rounded border p-3"><legend>Saldos de exercícios anteriores</legend><p>Se dispõe de remanescentes documentados de crédito internacional ou PEC, prepare a origem e as utilizações efectivas. Os resultados anteriores ficam conservados.</p><button type="button" onClick={() => save(prepareCreditCarry(book))}>Preparar saldos anteriores</button></fieldset>;
|
||||
const update = (value: CreditCarry) => save({...book, creditCarry: value});
|
||||
const lotChange = (r: CarryLot) => update({...c, lots: c.lots.map(x => x.id === r.id ? r : x)});
|
||||
return <fieldset className="space-y-3 rounded border p-3"><legend>Crédito internacional e PEC — exercícios anteriores</legend>
|
||||
<p>{carrySource}</p>
|
||||
<label className="block">Inventário de saldos anteriores<select aria-label="Estado dos saldos anteriores L8" value={c.state} onChange={e => update({...c, state: e.target.value as CreditCarry['state']})}><option value="">Informação em falta</option><option value="known">Saldos conhecidos</option><option value="absent">Ausência documentada</option></select></label>
|
||||
<label className="block">Suporte do inventário<input aria-label="Suporte dos saldos anteriores L8" value={c.reference} onChange={e => update({...c, reference: e.target.value})}/></label>
|
||||
{(c.state === 'known' || c.lots.length > 0) && <>
|
||||
<p>Consolide uma origem por natureza, país e exercício. Transcreva o montante elegível apurado e o deduzido na origem, com o suporte da regra desse exercício. Imposto estrangeiro acima dos limites de elegibilidade não é remanescente por insuficiência de colecta. Só se aplica aqui o prazo ordinário a exercícios anuais; situações transitórias ou especiais exigem correspondência própria.</p>
|
||||
<button type="button" onClick={() => update({...c, lots: [...c.lots, {...newCarryLot(crypto.randomUUID()), subjectNuit: book.subjectNuit}]})}>Registar origem de saldo anterior</button>
|
||||
</>}
|
||||
{c.lots.map((r, i) => {
|
||||
const patch = (v: Partial<CarryLot>) => lotChange({...r, ...v});
|
||||
const prefix = 'Saldo anterior ' + (i + 1) + ' — ';
|
||||
return <fieldset key={r.id} className="space-y-2 rounded border p-3"><legend>Origem {i + 1}</legend>
|
||||
<label className="block">Natureza<select aria-label={prefix + 'Natureza'} value={r.kind} onChange={e => patch({kind: e.target.value as CarryLot['kind']})}><option value="">Por identificar</option><option value="international">Crédito por dupla tributação internacional</option><option value="pec">PEC efectivamente pago</option></select></label>
|
||||
{(r.kind === 'international' || r.country) && <CountrySelect countries={countries} label={prefix + 'País estrangeiro'} value={r.country} onChange={country => patch({country})} allowLegacyValue/>}
|
||||
{r.kind === 'pec' && <p>O PEC não tem país estrangeiro. A escolha da natureza conserva os valores introduzidos; reconcilie qualquer país anterior.</p>}
|
||||
{([['subjectNuit', 'NUIT titular'], ['year', 'Exercício de origem'], ['currency', 'Moeda comprovada'], ['originDocument', 'Declaração/apuramento de origem'], ['originReference', 'Suporte da origem e insuficiência de colecta'], ['ruleReference', 'Regra e período aplicáveis na origem'], ['eligible', 'Montante elegível na origem — MZN'], ['originDeducted', 'Deduzido na origem — MZN'], ['selected', 'Parcela escolhida para este fecho — MZN']] as const).map(([key, label]) => <label key={key} className="block">{label}<input aria-label={prefix + label} value={r[key]} onChange={e => patch({[key]: e.target.value})}/></label>)}
|
||||
<label className="block">Exercício de origem anual ordinário<select aria-label={prefix + 'Período ordinário'} value={r.ordinary} onChange={e => patch({ordinary: e.target.value as CarryLot['ordinary']})}><option value="">Informação em falta</option><option value="yes">Sim — documentado</option><option value="no">Não — regra específica por corresponder</option></select></label>
|
||||
<label className="block">Remanescente por insuficiência de colecta<select aria-label={prefix + 'Insuficiência de colecta'} value={r.insufficient} onChange={e => patch({insufficient: e.target.value as CarryLot['insufficient']})}><option value="">Informação em falta</option><option value="yes">Sim — documentado</option><option value="no">Não — não é saldo elegível neste percurso</option></select></label>
|
||||
<label className="block">Utilizações efectivas posteriores à origem<select aria-label={prefix + 'Estado dos usos'} value={r.usesState} onChange={e => patch({usesState: e.target.value as CarryLot['usesState']})}><option value="">Informação em falta</option><option value="known">Utilizações conhecidas</option><option value="absent">Ausência documentada</option></select></label>
|
||||
<label className="block">Suporte do inventário de utilizações<input aria-label={prefix + 'Suporte dos usos'} value={r.usesReference} onChange={e => patch({usesReference: e.target.value})}/></label>
|
||||
<p>Inclua utilizações efectivas já ocorridas neste exercício antes desta parcela. Não registe simulações históricas como utilizações fiscais. Crédito internacional admite fracção exacta n/d; não é aplicado arredondamento fiscal.</p>
|
||||
{r.usesState === 'known' && <button type="button" onClick={() => patch({uses: [...r.uses, newCarryUse(crypto.randomUUID())]})}>Registar utilização efectiva da origem {i + 1}</button>}
|
||||
{r.uses.map((u, j) => <fieldset key={u.id} className="rounded border p-2"><legend>Utilização {i + 1}.{j + 1}</legend>
|
||||
{([['year', 'Exercício'], ['amount', 'Montante MZN'], ['document', 'Documento de utilização'], ['reference', 'Suporte']] as const).map(([key, label]) => <label key={key} className="block">{label}<input aria-label={prefix + 'Uso ' + (j + 1) + ' — ' + label} value={u[key]} onChange={e => patch({uses: r.uses.map(x => x.id === u.id ? {...x, [key]: e.target.value} : x)})}/></label>)}
|
||||
<button type="button" onClick={() => patch({uses: r.uses.filter(x => x.id !== u.id)})}>Retirar utilização {i + 1}.{j + 1}</button>
|
||||
</fieldset>)}
|
||||
<button type="button" onClick={() => update({...c, lots: c.lots.filter(x => x.id !== r.id)})}>Retirar origem {i + 1}</button>
|
||||
</fieldset>;
|
||||
})}
|
||||
{(c.state === 'known' || c.currentInternational || c.currentPec || c.allocationReference) && <fieldset className="space-y-2 rounded border p-3"><legend>Escolha das parcelas entre exercícios</legend>
|
||||
<p>Escolha quanto deduzir dos créditos correntes e de cada origem anterior. Os créditos internacionais precedem o PEC; a soma fica limitada à colecta. Não se presume prioridade entre exercícios e nenhuma parcela é reduzida automaticamente. Valores não escolhidos ficam identificados, sem criação de transporte oficial.</p>
|
||||
<label className="block">Crédito internacional corrente a deduzir — MZN<input aria-label="Parcela corrente internacional L8" value={c.currentInternational} onChange={e => update({...c, currentInternational: e.target.value})}/></label>
|
||||
<label className="block">PEC corrente a deduzir — MZN<input aria-label="Parcela corrente PEC L8" value={c.currentPec} onChange={e => update({...c, currentPec: e.target.value})}/></label>
|
||||
<label className="block">Suporte da escolha e seus impactos<input aria-label="Suporte da afectação entre exercícios L8" value={c.allocationReference} onChange={e => update({...c, allocationReference: e.target.value})}/></label>
|
||||
<button type="button" onClick={() => update({...c, assessmentKey: book.assessmentKey})}>Confirmar afectação à colecta seleccionada</button>
|
||||
<p>{c.assessmentKey && c.assessmentKey === book.assessmentKey ? 'Escolha vinculada à colecta seleccionada; o cálculo verifica disponibilidade e limites.' : 'Falta confirmar a escolha na colecta seleccionada. Valores conservados.'}</p>
|
||||
</fieldset>}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {useState} from 'react';
|
||||
import {costRules,newNormalCosts,newCostRow,reusableCreditBalance,type CostRow,type NormalCostsInput} from '@/lib/irpc-normal-costs';
|
||||
import {normalFinancialSource,type NormalBook} from '@/lib/irpc-normal';
|
||||
import {fiscalMoney} from '@/lib/irpc-normal-schedules';
|
||||
import {addFractions,subtractFractions,fractionPresentation} from '@/lib/fiscal-rational';
|
||||
import {type ProfitTopic} from '@/lib/irpc-normal-profit';
|
||||
type Field=[string,string,'money'|'text'|'choice'|'method'|'source'];
|
||||
const fields:Record<string,Field[]>={
|
||||
allowance:[['billed','Parcela facturada a clientes','money'],['taxedNotBilled','Parcela tributada emIRPS e não facturada a clientes','money']],
|
||||
advertising:[['campaign','Campanha de lançamento com horizonte superior a um ano','choice']],
|
||||
vehicle:[['passenger','Viatura ligeira de passageiros','choice'],['publicOrRental','Afecta a serviço público de transportes ou aluguer na actividade normal','choice']],
|
||||
fuel:[['supportedNormal','Parcela com activo/locação e consumo normal comprovados','money'],['passenger','Viatura ligeira de passageiros','choice'],['publicOrRental','Afecta a serviço público de transportes ou aluguer na actividade normal','choice']],
|
||||
rental:[['nonAcceptedQuota','Parcela documentada da quota não aceite (36h)','money'],['quotaReference','Documento e regra da quota não aceite','text'],['passenger','Aplicação36(4) — ligeira de passageiros','choice'],['publicOrRental','Afecta a serviço público de transportes ou aluguer na actividade normal','choice']],
|
||||
stock:[['balanceKey','Identificação única do inventário/objecto','text'],['lossObserved','Perda de valor efectivamente observada','choice'],['observedLoss','Perda total observada documentada','money'],['previous','Provisão fiscal anterior que subsiste','money']],
|
||||
litigation:[['balanceKey','Identificação única do processo/objecto','text'],['caseOngoing','Processo judicial em curso','choice'],['chargeDeductible','Obrigação daria lugar a custo fiscal dedutível','choice'],['obligation','Obrigação coberta documentada','money'],['previous','Provisão fiscal anterior que subsiste','money']],
|
||||
provision_reversal:[['balanceKey','Identificação única da provisão/objecto','text'],['previous','Parcela antes fiscalmente deduzida','money'],['eventEnded','Evento não subsiste ou utilização para fim diferente','choice'],['inResult','Reversão já incluída em proveitos do resultado actual','choice']],
|
||||
taxed_reversal:[['balanceKey','Identificação única da provisão/objecto','text'],['previous','Parcela antes tributada e não deduzida','money'],['eventEnded','Evento não subsiste ou utilização para fim diferente','choice'],['inResult','Reversão já incluída em proveitos do resultado actual','choice']],
|
||||
prior_cost:[['originalYear','Exercício de origem','text'],['unknownAtClose','Imprevisível ou manifestamente desconhecido no encerramento anterior','choice'],['closingEvidence','Suporte do conhecimento no encerramento anterior','text']],
|
||||
prior_income:[['originalYear','Exercício de origem','text'],['unknownAtClose','Imprevisível ou manifestamente desconhecido no encerramento anterior','choice'],['closingEvidence','Suporte do conhecimento no encerramento anterior','text']],
|
||||
work:[['projectKey','Identificação única da obra','text'],['method','Critério fiscal da obra','method'],['price','Preço contratual estabelecido','money'],['totalCost','Custo total estimado para concluir','money'],['incurred','Custos efectivamente incorporados acumulados','money'],['priorRevenue','Proveitos fiscais reconhecidos em exercícios anteriores','money'],['priorCost','Custos fiscais reconhecidos em exercícios anteriores','money'],['costSource','Linha de custos da obra no resultado actual','source'],['bookCost','Parcela de custos da obra já contabilizada no exercício','money'],['partialBilling','Facturação parcial com grau de acabamento correspondente','choice'],['ownFractionalDelivery','Obra por conta própria vendida e entregue fraccionadamente','choice'],['consistentMethod','Critério uniforme para obras idênticas e mantido até ao final','choice'],['publicReception','Recepção provisória de obra pública','choice'],['deferredReceipt','Receita antecipada19(5) proposta — zero conhecido se não aplicada','money']],
|
||||
};
|
||||
export function SimulationIrpcCosts({draft,book,save}:{draft:Record<string,unknown>;book:NormalBook;save:(b:NormalBook)=>void}){
|
||||
const[review,setReview]=useState(false),[error,setError]=useState(''),i=book.costs,source=normalFinancialSource(draft);
|
||||
if(!i)return<button type="button" onClick={()=>save({...book,costs:newNormalCosts()})}>Iniciar custos com limites, provisões e periodização</button>;
|
||||
const patch=(p:Partial<NormalCostsInput>)=>{setReview(false);save({...book,costs:{...i,...p}});};
|
||||
const row=(id:string,p:Partial<CostRow>)=>patch({rows:i.rows.map(r=>r.id===id?{...r,...p}:r)});
|
||||
const fact=(r:CostRow,k:string,v:string)=>row(r.id,{facts:{...r.facts,[k]:v}});
|
||||
const summaries=()=>{const groups=new Map<ProfitTopic,string[]>();for(const r of book.schedules?.rows??[]){if(!r.rule)continue;const k=({tax_own:'expenseCorrections',tax_other:'expenseCorrections',undocumented:'expenseCorrections',capital_in:'positivePatrimonial',patrimonial_gain:'positivePatrimonial',capital_out:'negativePatrimonial',patrimonial_cost:'negativePatrimonial',future_cost:'timing',future_income:'timing',current_cost:'timing',current_income:'timing'}as Record<string,ProfitTopic>)[r.rule];groups.set(k,[...(groups.get(k)??[]),r.amount]);}for(const r of i.rows)if(r.rule){const k=costRules[r.rule][1];groups.set(k,[...(groups.get(k)??[]),r.amount]);}return[...groups].map(([key,values])=>({key,amount:fractionPresentation(addFractions(values.map(fiscalMoney))).decimal}));};
|
||||
const reuse=(r:CostRow)=>{try{const code=r.sourceCode,l=source?.accountingLines.find(l=>l.code===code);if(!l)throw Error('Seleccione uma origem.');const claims=[...book.rows,...(book.schedules?.rows??[]),...i.rows.filter(q=>q.id!==r.id),...i.rows.filter(q=>q.rule==='work').map(q=>({sourceCode:q.facts.costSource,amount:q.facts.bookCost}))].filter(q=>q.sourceCode===code);const available=subtractFractions(fiscalMoney(l.total),addFractions(claims.map(q=>fiscalMoney(q.amount))));if(BigInt(available.numerator)<=0n)throw Error('Não há parcela livre conhecida nesta origem.');row(r.id,{amount:fractionPresentation(available).decimal});setError('');}catch(e){setError(String(e));}};
|
||||
return<fieldset aria-label="Custos com limites provisões periodização" className="space-y-3 rounded border p-3"><legend>Custos com limites, provisões e periodização — exercício2026</legend>
|
||||
<p>Reutiliza o resultado e as linhas já declaradas. Identifique só a parcela de cada categoria e os parâmetros em falta. Os limites anuais de publicidade e créditos são partilhados por todas as parcelas; os saldos de provisões não são consumidos. A divergência M20/M22 não bloqueia estes cálculos. Benefícios/casos especiaisL7 e saldo anualL8 mantêm os seus âmbitos.</p>{error&&<p role="alert">{error}</p>}
|
||||
<button type="button" onClick={()=>patch({rows:[...i.rows,newCostRow(crypto.randomUUID())]})}>Acrescentar custo, provisão ou periodização</button>
|
||||
{i.rows.some(r=>r.rule==='advertising')&&<fieldset><legend>Publicidade — limite único anual</legend><p>Volume de vendas da origem: {source?.accountingLines.find(l=>l.code==='revenue')?.total??'Em falta'} MZN. O limite não se repete por factura.</p><label>Correspondência às vendas normais do exercício<select aria-label="Vendas normais para publicidade" value={i.salesOrdinary} onChange={e=>patch({salesOrdinary:e.target.value as ''|'yes'})}><option value="">Informação em falta</option><option value="yes">Toda a receita da origem corresponde a vendas/serviços normais</option></select></label><label className="block">Suporte do volume de vendas e completude da publicidade<input value={i.advertisingReference} onChange={e=>patch({advertisingReference:e.target.value})}/></label><p>Campanhas de lançamento com efeito superior a um ano exigem36-A e articulação própria; permanecem discriminadas, sem dedução ordinária presumida.</p></fieldset>}
|
||||
{i.rows.some(r=>r.rule==='credit')&&<fieldset><legend>Créditos — limites anual e acumulado</legend><label>Regime da provisão de créditos<select aria-label="Regime da provisão de créditos" value={i.creditRegime} onChange={e=>patch({creditRegime:e.target.value as NormalCostsInput['creditRegime']})}><option value="">Por identificar</option><option value="ordinary">Ordinário — fora de supervisão financeira/seguradora</option><option value="regulated">Provisão sujeita a supervisão — correspondência própria</option></select></label><label className="block">Créditos da actividade normal existentes no fim do exercício<input value={i.creditBase} onChange={e=>patch({creditBase:e.target.value})}/></label>{reusableCreditBalance(source)&&<button type="button" onClick={()=>patch({creditBase:fractionPresentation(reusableCreditBalance(source)!).decimal})}>Reutilizar saldo financeiro de créditos: {fractionPresentation(reusableCreditBalance(source)!).decimal} MZN — conferir âmbito</button>}<label className="block">Provisão fiscal anterior que subsiste no termo do exercício<input value={i.creditPrevious} onChange={e=>patch({creditPrevious:e.target.value})}/></label><label className="block">Suporte do saldo de créditos e provisão anterior<input value={i.creditReference} onChange={e=>patch({creditReference:e.target.value})}/></label><p>1,5% anual e6% acumulado; saldo anterior em falta não significa zero. Não aplica regras bancárias/seguradoras nem percentagens de antiguidade presumidas.</p></fieldset>}
|
||||
{i.rows.map((r,n)=>{const income=['provision_reversal','taxed_reversal','prior_income','work'].includes(r.rule),omitted=r.rule==='provision_reversal'&&r.facts.inResult==='no';return<fieldset key={r.id} className="rounded border p-3"><legend>Parcela {n+1} — {r.document||'Por identificar'}</legend><label>Categoria<select aria-label={'Categoria custo '+(n+1)} value={r.rule} onChange={e=>row(r.id,{rule:e.target.value as CostRow['rule']})}><option value="">Por identificar</option>{Object.entries(costRules).map(([k,v])=><option key={k} value={k}>{v[0]} — {v[2]}</option>)}</select></label>
|
||||
{(['document','line','amount','reference']as const).map(k=><label key={k} className="block">{({document:'Documento ou mapa discriminado',line:'Linha distinta',amount:r.rule==='work'?'Proveitos da obra já contabilizados no exercício — zero se conhecido':'Parcela contabilística MZN',reference:'Suporte da classificação, condições e período'})[k]}<input aria-label={'Custo '+(n+1)+' '+k} value={r[k]} onChange={e=>row(r.id,{[k]:e.target.value})}/></label>)}
|
||||
{!omitted&&<><label>Origem contabilística<select aria-label={'Origem custo '+(n+1)} value={r.sourceCode} onChange={e=>row(r.id,{sourceCode:e.target.value})}><option value="">Por corresponder</option>{source?.accountingLines.filter(l=>l.accountingEffect===(income?'income':'expense')).map(l=><option key={l.code} value={l.code}>{l.origin}: {l.total} MZN</option>)}</select></label><button type="button" onClick={()=>reuse(r)}>Reutilizar parcela livre da origem {n+1}</button></>}
|
||||
{(fields[r.rule]??[]).filter(([k])=>!(k==='publicOrRental'&&r.facts.passenger!=='yes')&&!(k==='publicReception'&&r.facts.method!=='closing')&&!(r.rule==='stock'&&r.facts.lossObserved==='no'&&['observedLoss','previous'].includes(k))&&!(r.rule==='litigation'&&r.facts.caseOngoing==='no'&&['obligation','previous','chargeDeductible'].includes(k))).map(([k,label,type])=><label key={k} className="block">{label}{type==='choice'?<select aria-label={'Custo '+(n+1)+' '+k} value={r.facts[k]??''} onChange={e=>fact(r,k,e.target.value)}><option value="">Informação em falta</option><option value="yes">Sim — documentado</option><option value="no">Não — documentado</option></select>:type==='method'?<select aria-label={'Custo '+(n+1)+' '+k} value={r.facts[k]??''} onChange={e=>fact(r,k,e.target.value)}><option value="">Por identificar</option><option value="percentage">Percentagem de acabamento</option><option value="closing">Encerramento da obra</option></select>:type==='source'?<select aria-label={'Custo '+(n+1)+' '+k} value={r.facts[k]??''} onChange={e=>fact(r,k,e.target.value)}><option value="">Por corresponder/zero fora de linha</option>{source?.accountingLines.filter(l=>l.accountingEffect==='expense').map(l=><option key={l.code} value={l.code}>{l.origin}: {l.total} MZN</option>)}</select>:<input aria-label={'Custo '+(n+1)+' '+k} value={r.facts[k]??''} onChange={e=>fact(r,k,e.target.value)}/>}</label>)}
|
||||
<label>Condições ordinárias e inclusão na origem demonstradas<select aria-label={'Condições custo '+(n+1)} value={r.conditions} onChange={e=>row(r.id,{conditions:e.target.value as ''|'yes'})}><option value="">Informação em falta</option><option value="yes">Sim, nos documentos identificados</option></select></label><button type="button" onClick={()=>patch({rows:i.rows.filter(q=>q.id!==r.id)})}>Retirar parcela {n+1} das entradas actuais</button></fieldset>;})}
|
||||
{book.profit&&<><button type="button" onClick={()=>{try{summaries();setReview(true);setError('');}catch(e){setError(String(e));}}}>Conferir correspondência dos temas com os mapas</button>{review&&<div><p>Reveja as somas brutas antes de aplicar; não alteram os factos, o resultado financeiro ou os históricos.</p>{summaries().map(t=><p key={t.key}>{({expenseCorrections:'Correcções aos custos',incomeCorrections:'Correcções aos proveitos',positivePatrimonial:'Variações positivas',negativePatrimonial:'Variações negativas',timing:'Periodização',special:'Casos especiais'})[t.key]}: actual {book.profit!.topics[t.key].amount||book.profit!.topics[t.key].state||'Em falta'} → mapas {t.amount} MZN</p>)}<button type="button" onClick={()=>{const topics=structuredClone(book.profit!.topics);for(const t of summaries())topics[t.key]={state:'known',amount:t.amount,reference:'Correspondência aritmética aos documentos/linhas dos mapas de ajustamentos e custos do exercício.'};save({...book,profit:{...book.profit!,topics}});setReview(false);}}>Aplicar correspondências conferidas dos mapas</button></div>}</>}
|
||||
<p>Ao calcular e guardar, os novos resultados entram numa versão própria; o resultado anterior permanece no histórico. Informação em falta impede apenas os totais que dependem dessa parcela.</p>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {useState}from'react';
|
||||
import{depreciationCatalog,newNormalDepreciation,newDepreciationAsset,reuseNormalAssets,type DepreciationAsset}from'@/lib/irpc-normal-depreciation';
|
||||
import{normalFinancialSource,type NormalBook}from'@/lib/irpc-normal';
|
||||
import{fiscalMoney}from'@/lib/irpc-normal-schedules';import{addFractions,subtractFractions,fractionPresentation}from'@/lib/fiscal-rational';
|
||||
export function SimulationIrpcDepreciation({draft,book,save}:{draft:Record<string,unknown>;book:NormalBook;save:(b:NormalBook)=>void}){
|
||||
const[error,setError]=useState(''),input=book.depreciation,source=normalFinancialSource(draft);
|
||||
if(!input)return<div><button type="button" onClick={()=>save({...book,depreciation:newNormalDepreciation()})}>Iniciar amortizações — tabelas, métodos e quotas anteriores</button>{!!book.schedules?.assets.length&&<button type="button" onClick={()=>save({...book,depreciation:{...reuseNormalAssets(book.schedules!.assets),completenessReference:book.schedules!.assetsReference}})}>Reutilizar activos e valores do mapa inicial</button>}</div>;
|
||||
const patch=(id:string,p:Partial<DepreciationAsset>)=>save({...book,depreciation:{...input,assets:input.assets.map(a=>a.id===id?{...a,...p}:a)}});
|
||||
const field=(a:DepreciationAsset,n:number,k:keyof DepreciationAsset,label:string)=><label key={k}className="block">{label}<input aria-label={'Activo '+n+' '+k}value={a[k]}onChange={e=>patch(a.id,{[k]:e.target.value})}/></label>;
|
||||
const choice=(a:DepreciationAsset,n:number,k:'specificRate'|'consistentMethod'|'newTangible'|'switchToStraight'|'priorFullQuotas'|'vehicleException'|'limitedRights'|'individual',label:string)=><label key={k}className="block">{label}<select aria-label={'Activo '+n+' '+k}value={a[k]}onChange={e=>patch(a.id,{[k]:e.target.value})}><option value="">Informação em falta</option><option value="yes">Sim — documentado</option><option value="no">Não — documentado</option></select></label>;
|
||||
return<fieldset aria-label="Amortizações ampliadas L6"className="space-y-3 rounded border p-3"><legend>Amortizações — tabelas, métodos, duodécimos e quotas anteriores</legend>
|
||||
<p>Decreto72/2013 eCIRPC26/27. Reutiliza a depreciação financeira e os activos do mapa inicial; os originais/históricos ficam conservados. O novo mapa substitui exclusivamente o cálculo de amortizações, sem somar novamente o mapa inicial. Custo histórico e condições ordinárias documentados; reavaliações, turnos intensivos, concessões e outros casos especiais permanecem separados.</p>
|
||||
<p>Depreciação contabilística a repartir: {source?.accountingLines.find(l=>l.code==='depreciation')?.total??'Em falta'} {source?.currency}. O catálogo apresenta apenas os códigos correspondidos; outra categoria permanece identificada, sem taxa livre.</p>{error&&<p role="alert">{error}</p>}
|
||||
<label className="block">Suporte da reconciliação integral dos activos e quotas anteriores<input value={input.completenessReference}onChange={e=>save({...book,depreciation:{...input,completenessReference:e.target.value}})}/></label>
|
||||
<button type="button"onClick={()=>save({...book,depreciation:{...input,assets:[...input.assets,newDepreciationAsset(crypto.randomUUID())]}})}>Acrescentar activo ao mapa fiscal</button>
|
||||
{input.assets.map((a,index)=>{const n=index+1,entry=Object.hasOwn(depreciationCatalog,a.category)?depreciationCatalog[a.category as keyof typeof depreciationCatalog]:null;return<fieldset key={a.id}className="rounded border p-3"><legend>Activo fiscal {n} — {a.description||'Por identificar'}</legend>
|
||||
{field(a,n,'assetKey','Identificação única do activo')}{field(a,n,'description','Descrição')}{field(a,n,'document','Documento, pressuposto ou mapa de origem')}{field(a,n,'line','Linha distinta do documento/mapa')}
|
||||
<label>Categoria/tabela<select aria-label={'Activo '+n+' category'}value={a.category}onChange={e=>{const category=e.target.value as DepreciationAsset['category'],c=Object.hasOwn(depreciationCatalog,category)?depreciationCatalog[category as keyof typeof depreciationCatalog]:null;patch(a.id,{category,rate:c?.rate??''});}}><option value="">Por identificar</option>{Object.entries(depreciationCatalog).map(([k,c])=><option key={k}value={k}>Tabela{c.table}/{c.code} — {c.label} — {c.rate}%</option>)}<option value="nondepreciable">Elemento comprovadamente não depreciável — exclusão</option><option value="other">Outra categoria — correspondência pendente</option></select></label>
|
||||
{entry&&<p>Taxa máxima: {entry.rate}%; taxas constantes cobertas: {Number(entry.rate)/2}% a{entry.rate}%; vida útil mínima: {100/Number(entry.rate)}anos, máxima: {200/Number(entry.rate)}anos. Coeficiente degressivo máximo: {100/Number(entry.rate)<5?'1,5':100/Number(entry.rate)<=6?'2':'2,5'}, apenas nos activos elegíveis. Fonte:tabela{entry.table}/{entry.code},PDF{entry.page}.</p>}{field(a,n,'cost','Custo histórico documentado MZN')}{field(a,n,'accounting','Depreciação contabilística do exercício MZN')}
|
||||
<button type="button"onClick={()=>{try{const total=fiscalMoney(source?.accountingLines.find(l=>l.code==='depreciation')?.total??''),used=addFractions(input.assets.filter(q=>q.id!==a.id).map(q=>fiscalMoney(q.accounting))),left=subtractFractions(total,used);if(BigInt(left.numerator)<0n)throw Error('Valores repartidos excedem a origem.');patch(a.id,{accounting:fractionPresentation(left).decimal});setError('');}catch(e){setError(String(e));}}}>Reutilizar depreciação financeira ainda não repartida — activo {n}</button>
|
||||
{a.category!=='nondepreciable'&&<>
|
||||
<label>Tipo de aquisição/base<select aria-label={'Activo '+n+' valuation'} value={a.valuation} onChange={e=>patch(a.id,{valuation:e.target.value as DepreciationAsset['valuation']})}><option value="">Por identificar</option><option value="historical_new">Bem adquirido novo/construído ou produzido — custo histórico</option><option value="used">Bem adquirido usado — regra própria7(2)</option><option value="revalued">Bem reavaliado — regra própria15</option><option value="other">Outra base — por corresponder</option></select></label>{entry&&choice(a,n,'specificRate','Existe taxa específica da tabelaI para o activo/actividade')}{field(a,n,'residual','Valor residual MZN')}{entry?.kind==='building'&&field(a,n,'land','Terreno/parte não depreciável incluídos no custo MZN')}
|
||||
<label>Método<select aria-label={'Activo '+n+' method'}value={a.method}onChange={e=>patch(a.id,{method:e.target.value as DepreciationAsset['method']})}><option value="">Por identificar</option><option value="constant">Quotas constantes — artigos5/7</option><option value="declining">Quotas degressivas — artigos6/8</option><option value="low_value">Valor reduzido, opção integral — artigo17</option></select></label>
|
||||
{a.method==='constant'&&field(a,n,'rate','Taxa constante escolhida %')}
|
||||
{a.method==='declining'&&<>{choice(a,n,'newTangible','Activo tangível novo adquirido a terceiros ou construído/produzido')}{field(a,n,'factor','Coeficiente degressivo escolhido (máximo1,5/2/2,5)')}{choice(a,n,'switchToStraight','Aplicar saldo/vida útil restante8(2) quando mais favorável')}{a.switchToStraight==='yes'&&choice(a,n,'priorFullQuotas','Condição de quotas anteriores do artigo8(2) integralmente documentada')}</>}
|
||||
{a.method==='low_value'?<>{choice(a,n,'individual','Activo individual, fora de conjunto depreciado como um todo')}<p>Art17:até20000MZN, sem amortização fiscal anterior e entrada emuso neste período; a dedução não excede o contabilizado e o saldo fiscal.</p></>:<label>Período da quota<select aria-label={'Activo '+n+' timing'}value={a.timing}onChange={e=>patch(a.id,{timing:e.target.value as DepreciationAsset['timing']})}><option value="">Por identificar</option><option value="annual">Quota anual</option><option value="months">Duodécimos em vigor desde o início — artigo9</option></select></label>}
|
||||
{field(a,n,'start','Entrada em funcionamento/utilização AAAA-MM-DD')}
|
||||
<label>Transmissão, inutilização ou termo da vida útil<select aria-label={'Activo '+n+' exitState'}value={a.exitState}onChange={e=>patch(a.id,{exitState:e.target.value as DepreciationAsset['exitState']})}><option value="">Informação em falta</option><option value="absent">Ausência declarada de evento</option><option value="known">Evento/data conhecidos</option></select></label>{(a.exitState==='known'||a.exitDate)&&field(a,n,'exitDate','Data do evento AAAA-MM-DD')}
|
||||
{choice(a,n,'consistentMethod','Método uniforme desde o início, sem mudança ou excepção por corresponder')}{entry?.kind==='vehicle'&&choice(a,n,'vehicleException','Afectação a serviço público de transportes/locação na actividade normal')}{entry?.kind==='software'&&choice(a,n,'limitedRights','Aquisição onerosa e utilização exclusiva por tempo limitado')}
|
||||
{field(a,n,'previousAccounting','Quotas contabilísticas anteriores desde a utilização MZN')}{field(a,n,'previousFiscal','Quotas fiscais anteriormente deduzidas MZN')}{field(a,n,'priorAvailable','Parcela anterior temporariamente tributada, recuperável MZN')}{field(a,n,'previousReference','Suporte das quotas anteriores e da recuperabilidade fiscal')}
|
||||
{['previousAccounting','previousFiscal','priorAvailable'].every(k=>!a[k as 'previousFiscal']||/^0(?:\.0+)?$/.test(a[k as 'previousFiscal']))&&<button type="button"onClick={()=>{if(!a.reference.trim()){setError('Identifique primeiro o suporte da ausência de quotas anteriores.');return;}patch(a.id,{previousAccounting:'0',previousFiscal:'0',priorAvailable:'0',previousReference:'Ausência de quotas anteriores expressamente declarada; '+a.reference});setError('');}}>Declarar ausência de quotas anteriores — activo {n}</button>}
|
||||
<p>Quota anterior recuperável exige contabilização e tributação temporária demonstradas. Não inclui encargos permanentemente excluídos ou quota nunca contabilizada. O cálculo apresenta o saldo posterior aritmético; não consome saldos oficiais.</p></>}
|
||||
{field(a,n,'reference','Suporte do activo, categoria, condições e período')}<label>Condições ordinárias e documentos conferidos<select aria-label={'Activo '+n+' conditions'}value={a.conditions}onChange={e=>patch(a.id,{conditions:e.target.value as ''|'yes'})}><option value="">Informação em falta</option><option value="yes">Sim, no recorte documentado</option></select></label>
|
||||
<button type="button"onClick={()=>save({...book,depreciation:{...input,assets:input.assets.filter(q=>q.id!==a.id)}})}>Retirar activo {n} das entradas actuais</button></fieldset>;})}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {prepareEconomicBook, type AnnualBook} from '@/lib/irpc-annual-settlement';
|
||||
import {economicSource, newEconomicPosition, type EconomicPosition} from '@/lib/irpc-economic-credit';
|
||||
|
||||
export function SimulationIrpcEconomic({book, save, residentConfirmed}: {book: AnnualBook; save: (book: AnnualBook) => void; residentConfirmed: boolean}) {
|
||||
const c = book.economicCredits;
|
||||
if (!c) return <fieldset className="rounded border p-3"><legend>Crédito por dupla tributação económica</legend><p>Para lucros distribuídos por entidade moçambicana sujeita e não isenta de IRPC, distinga o imposto correspondente aos lucros da retenção sofrida e do imposto estrangeiro. Reutiliza a colecta e conserva o resumo de créditos anterior.</p><button type="button" onClick={() => save(prepareEconomicBook(book))}>Preparar crédito económico</button></fieldset>;
|
||||
const update = (next: typeof c) => save({...book, economicCredits: next});
|
||||
const rowChange = (r: EconomicPosition) => update({...c, rows: c.rows.map(x => x.id === r.id ? r : x)});
|
||||
return <fieldset className="space-y-3 rounded border p-3"><legend>Crédito económico — lucros distribuídos</legend>
|
||||
<p>{economicSource}</p>
|
||||
<p>O detalhe substitui nesta execução o resumo económico anterior, que permanece conservado: {book.internationalCredits?.economic.state || 'informação em falta'}; montante {book.internationalCredits?.economic.amount || 'não preenchido'}. Os benefícios e o crédito internacional mantêm os seus estados próprios.</p>
|
||||
<label className="block">Estado do crédito económico<select aria-label="Estado do crédito económico L8" value={c.state} onChange={e => update({...c, state: e.target.value as typeof c.state})}><option value="">Informação em falta</option><option value="known">Lucros e IRPC conhecidos</option><option value="absent">Ausência documentada</option></select></label>
|
||||
<label className="block">Suporte do inventário económico<input aria-label="Suporte económico L8" value={c.reference} onChange={e => update({...c, reference: e.target.value})}/></label>
|
||||
{(c.state === 'known' || c.rows.length > 0) && <button type="button" onClick={() => update({...c, state: 'known', rows: [...c.rows, {...newEconomicPosition(crypto.randomUUID()), subjectNuit: book.subjectNuit, year: book.year, recipientResident: residentConfirmed ? 'yes' : ''}]})}>Registar lucros distribuídos</button>}
|
||||
{c.rows.map((r, i) => {
|
||||
const patch = (v: Partial<EconomicPosition>) => rowChange({...r, ...v});
|
||||
const prefix = 'Crédito económico ' + (i + 1) + ' — ';
|
||||
const field = (key: Exclude<keyof EconomicPosition, 'previousUse'>, label: string) => <label key={key} className="block">{label}<input aria-label={prefix + label} value={r[key]} onChange={e => patch({[key]: e.target.value})}/></label>;
|
||||
const choice = (key: 'recipientResident' | 'payerResident' | 'payerIrpc' | 'payerExempt' | 'article40' | 'incomeIncluded', label: string) => <label key={key} className="block">{label}<select aria-label={prefix + label} value={r[key]} onChange={e => patch({[key]: e.target.value})}><option value="">Informação em falta</option><option value="yes">Sim — documentado</option><option value="no">Não — documentado</option></select></label>;
|
||||
const outside = !!r.kind && r.kind !== 'ordinary_profit' || r.article40 === 'yes';
|
||||
const detail = <>
|
||||
{([['distributionDocument', 'Documento da distribuição'], ['distributionParcel', 'Parcela dos lucros no documento'], ['distributionDate', 'Data da distribuição (AAAA-MM-DD)'], ['income', 'Lucros incluídos antes do crédito — MZN, ilíquidos de retenção'], ['incomeReference', 'Suporte da inclusão e do valor ilíquido na base corrente'], ['taxIssuer', 'Emitente do comprovativo de IRPC correspondente'], ['taxDocument', 'Documento do IRPC correspondente aos lucros'], ['taxParcel', 'Parcela desse IRPC no documento'], ['parcelReference', 'Suporte da identificação da parcela de imposto'], ['currency', 'Moeda comprovada'], ['proofAmount', 'IRPC comprovado nesta parcela — MZN'], ['taxAmount', 'IRPC correspondente aos lucros seleccionados — MZN'], ['attributionReference', 'Suporte da correspondência entre imposto e lucros'], ['previouslyAttributed', 'IRPC desta parcela já atribuído a outros lucros — MZN'], ['priorAttributionReference', 'Suporte das atribuições anteriores, incluindo zero conhecido'], ['grossUpIncluded', 'Crédito nominal já acrescido à base (art.53) — MZN'], ['grossUpReference', 'Suporte do acréscimo art.53 já incluído em L6'], ['nationalLimit', 'Quota proporcional da colecta após acréscimo — MZN'], ['nationalLimitReference', 'Suporte da quota proporcional da colecta']] as const).map(([key, label]) => field(key, label))}
|
||||
{choice('incomeIncluded', 'Lucros ilíquidos e acréscimo incluídos na base corrente')}
|
||||
<p>O cálculo confere 60% do IRPC correspondente com o acréscimo já incluído em L6 e a quota proporcional documentada. Se a base estiver incompleta, reconcilie-a no percurso existente. Esta acção conserva os valores e não altera a colecta automaticamente.</p>
|
||||
<button type="button" onClick={() => patch({assessmentKey: book.assessmentKey})}>Confirmar inclusão e quota na colecta seleccionada</button>
|
||||
<label className="block">Utilização anterior efectiva do crédito<select aria-label={prefix + 'Estado do uso anterior'} value={r.previousUse.state} onChange={e => patch({previousUse: {...r.previousUse, state: e.target.value as EconomicPosition['previousUse']['state']}})}><option value="">Informação em falta</option><option value="known">Montante conhecido</option><option value="absent">Ausência documentada</option></select></label>
|
||||
{(r.previousUse.state === 'known' || r.previousUse.amount) && <label className="block">Crédito já utilizado — MZN<input aria-label={prefix + 'Uso anterior MZN'} value={r.previousUse.amount} onChange={e => patch({previousUse: {...r.previousUse, amount: e.target.value}})}/></label>}
|
||||
<label className="block">Suporte do uso anterior ou da sua ausência<input aria-label={prefix + 'Suporte do uso anterior'} value={r.previousUse.reference} onChange={e => patch({previousUse: {...r.previousUse, reference: e.target.value}})}/></label>
|
||||
</>;
|
||||
return <fieldset key={r.id} className="space-y-2 rounded border p-3"><legend>Lucros {i + 1}</legend>
|
||||
{field('subjectNuit', 'NUIT beneficiário')}{field('payerNuit', 'NUIT distribuidor')}{field('year', 'Exercício')}
|
||||
<label className="block">Natureza<select aria-label={prefix + 'Natureza'} value={r.kind} onChange={e => patch({kind: e.target.value as EconomicPosition['kind']})}><option value="">Por identificar</option><option value="ordinary_profit">Lucros ordinários — art.64(1–2)</option><option value="liquidation">Partilha em liquidação — art.64(3)</option><option value="association">Associação — art.64(4)</option></select></label>
|
||||
{residentConfirmed && r.recipientResident === 'yes' ? <p>Residência do beneficiário reutilizada da colecta corrente no regime geral. NUIT e exercício conservados.</p> : choice('recipientResident', 'Beneficiário com sede/direcção efectiva em Moçambique')}{choice('payerResident', 'Distribuidor com sede/direcção efectiva em Moçambique')}{choice('payerIrpc', 'Distribuidor sujeito a IRPC')}{choice('payerExempt', 'Distribuidor isento de IRPC')}{field('qualificationReference', 'Suporte da qualificação das entidades e dos lucros')}
|
||||
{choice('article40', 'Esta parcela está abrangida pela exclusão do art.40(1)')}{field('article40Reference', 'Suporte da correspondência do art.40(1) e seu período')}
|
||||
<p>O art.40(1), na redacção de 2009, contempla as entidades e condições próprias, incluindo participação directa de pelo menos20% e prazo de dois anos, nos termos da fonte. Uma parcela excluída da base não recebe também o crédito64. A simples percentagem de participação não decide todas as condições.</p>
|
||||
{outside ? <><p>Esta parcela exige o tratamento específico identificado. O crédito ordinário não é aplicado; os valores introduzidos ficam conservados.</p><details><summary>Consultar ou reconciliar dados conservados</summary>{detail}</details></> : detail}
|
||||
<button type="button" onClick={() => update({...c, rows: c.rows.filter(x => x.id !== r.id)})}>Retirar lucros {i + 1}</button>
|
||||
</fieldset>;
|
||||
})}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {normalFinancialSource,type NormalBook} from '@/lib/irpc-normal';
|
||||
import {m20Accounts,newNormalFormPreparation,newFormAllocation,reuseDirectFormAllocations,normalFormAccountsFor,type NormalFormPreparationInput,type FormAllocation} from '@/lib/irpc-normal-form-preparation';
|
||||
export function SimulationIrpcFormPreparation({draft,book,save}:{draft:Record<string,unknown>;book:NormalBook;save:(b:NormalBook)=>void}){
|
||||
const i=book.forms,source=normalFinancialSource(draft);if(!i)return<button type="button" onClick={()=>save({...book,forms:newNormalFormPreparation()})}>Preparar representação dos valores e campos M20/M22</button>;
|
||||
const patch=(p:Partial<NormalFormPreparationInput>)=>save({...book,forms:{...i,...p}}),row=(id:string,p:Partial<FormAllocation>)=>patch({allocations:i.allocations.map(a=>a.id===id?{...a,...p}:a)});
|
||||
return<fieldset aria-label="Preparação de valores M20 M22" className="space-y-3 rounded border p-3"><legend>Representação dos valores e correspondência M20/M22</legend><p>Reutiliza os valores exactos do cálculo e do período/moeda já documentados. A preparação dos campos não altera lucro tributável,matéria colectável ou colecta. M20A.1 exige meticais sem centavos;fracções conservam o valor exacto e ficam pendentes de regra de representação. O transporte201/quadro8 versus200/quadro10 continua identificado,só no resultado afectado. Não gera declaração oficial nem saldo finalL8.</p>
|
||||
<label className="block">Exemplares de preparação<select value={i.edition} onChange={e=>patch({edition:e.target.value as NormalFormPreparationInput['edition']})}><option value="">Por identificar</option><option value="accepted_pdf_and_m20a1">PDF M22 quadros10–12 e M20A.1 inventariados — edições distintas</option></select></label>
|
||||
<label className="block">Suporte da edição e do período<input value={i.reference} onChange={e=>patch({reference:e.target.value})}/></label><label className="block">Completude das contas e discriminação das linhas do plano<input value={i.completenessReference} onChange={e=>patch({completenessReference:e.target.value})}/></label>
|
||||
<button type="button" onClick={()=>patch({allocations:[...i.allocations,...reuseDirectFormAllocations(source).filter(a=>!i.allocations.some(r=>r.sourceCode===a.sourceCode))]})}>Reutilizar contas directas ainda não discriminadas</button><button type="button" onClick={()=>patch({allocations:[...i.allocations,newFormAllocation(crypto.randomUUID())]})}>Acrescentar parcela contabilística M20</button>
|
||||
{i.allocations.map((a,n)=><fieldset key={a.id} className="rounded border p-2"><legend>Parcela M20 {n+1}</legend><label className="block">Linha de origem<select aria-label={'Origem M20 '+(n+1)} value={a.sourceCode} onChange={e=>row(a.id,{sourceCode:e.target.value})}><option value="">Seleccione</option>{source?.accountingLines.map(l=><option key={l.code} value={l.code}>{l.origin}: {l.total} MZN</option>)}</select></label><label className="block">Conta M20A.1<select aria-label={'Conta M20 '+(n+1)} value={a.account} onChange={e=>row(a.id,{account:e.target.value as FormAllocation['account']})}><option value="">Por corresponder</option>{Object.entries(m20Accounts).filter(([k])=>normalFormAccountsFor(a.sourceCode).includes(k)||k===a.account).map(([k,v])=><option key={k}value={k}>{k} — {v}</option>)}</select></label><label className="block">Montante MZN<input aria-label={'Montante M20 '+(n+1)}value={a.amount}onChange={e=>row(a.id,{amount:e.target.value})}/></label><label className="block">Suporte da classificação<input aria-label={'Suporte M20 '+(n+1)}value={a.reference}onChange={e=>row(a.id,{reference:e.target.value})}/></label></fieldset>)}
|
||||
<fieldset className="rounded border p-2"><legend>Imposto contabilizado e resultado líquido</legend><p>Colecta e saldo a pagar não são usados como gasto contabilístico presumido. Indique imposto reconhecido e se já está incluído numa linha do plano,para evitar subtracção duplicada.</p><label className="block">Estado do imposto contabilizado<select aria-label="Estado do imposto contabilizado" value={i.incomeTax.state} onChange={e=>patch({incomeTax:{...i.incomeTax,state:e.target.value as ''|'known'|'absent'}})}><option value="">Informação em falta</option><option value="known">Valor conhecido</option><option value="absent">Ausência declarada com suporte</option></select></label>{(i.incomeTax.state==='known'||i.incomeTax.amount!==''||i.incomeTax.placement!=='')&&<><label className="block">Imposto contabilizado em MZN<input value={i.incomeTax.amount} onChange={e=>patch({incomeTax:{...i.incomeTax,amount:e.target.value}})}/></label><label className="block">Localização do imposto contabilizado<select value={i.incomeTax.placement} onChange={e=>patch({incomeTax:{...i.incomeTax,placement:e.target.value as ''|'included'|'outside'}})}><option value="">Por identificar</option><option value="included">Já incluído no resultado do plano — discriminado na conta8.5</option><option value="outside">Exterior ao resultado antes de imposto do plano</option></select></label></>}<label className="block">Suporte do imposto contabilizado<input value={i.incomeTax.reference}onChange={e=>patch({incomeTax:{...i.incomeTax,reference:e.target.value}})}/></label></fieldset>
|
||||
<label className="block">Parcela de taxa reduzida para a colecta total302<select aria-label="Parcela de taxa reduzida para a colecta total302" value={i.reducedRate.state} onChange={e=>patch({reducedRate:{...i.reducedRate,state:e.target.value as ''|'known'|'absent'}})}><option value="">Informação em falta</option><option value="absent">Ausência declarada — sem parcela reduzida neste sujeito/período</option><option value="known">Situação conhecida — âmbitoL7 por tratar</option></select></label><label className="block">Suporte da situação da taxa reduzida<input value={i.reducedRate.reference}onChange={e=>patch({reducedRate:{...i.reducedRate,reference:e.target.value}})}/></label>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {FiscalProofParcelFields} from './fiscal-proof-parcel';
|
||||
import CountrySelect, {type Country} from '@/components/country-select';
|
||||
import {countryIsoCodes} from '@/lib/country-iso-codes';
|
||||
import {newInternationalProof, prepareInternationalBook, type AnnualBook} from '@/lib/irpc-annual-settlement';
|
||||
import {newInternationalPosition, internationalCreditSource, type CreditEvidence, type InternationalCredits, type InternationalPosition, type InternationalTaxProof} from '@/lib/irpc-international-credit';
|
||||
|
||||
const pt = new Intl.DisplayNames(['pt'], {type: 'region'}), en = new Intl.DisplayNames(['en'], {type: 'region'});
|
||||
const countries: Country[] = countryIsoCodes.filter(code => code !== 'MZ').map(code => ({code, name: pt.of(code) ?? code, name_en: en.of(code) ?? code})).sort((a, b) => a.name.localeCompare(b.name, 'pt'));
|
||||
function Evidence({value, label, change}: {value: CreditEvidence; label: string; change: (value: CreditEvidence) => void}) {
|
||||
return <fieldset className="rounded border p-2"><legend>{label}</legend>
|
||||
<label className="block">Estado<select aria-label={label + ' — Estado'} value={value.state} onChange={e => change({...value, state: e.target.value as CreditEvidence['state']})}><option value="">Informação em falta</option><option value="known">Valor conhecido</option><option value="absent">Ausência declarada</option></select></label>
|
||||
{(value.state === 'known' || value.amount) && <label className="block">Montante MZN<input aria-label={label + ' — Montante'} value={value.amount} onChange={e => change({...value, amount: e.target.value})}/></label>}
|
||||
<label className="block">Suporte<input aria-label={label + ' — Suporte'} value={value.reference} onChange={e => change({...value, reference: e.target.value})}/></label>
|
||||
</fieldset>;
|
||||
}
|
||||
export function SimulationIrpcInternational({book, save}: {book: AnnualBook; save: (book: AnnualBook) => void}) {
|
||||
const c = book.internationalCredits;
|
||||
if (!c) return <fieldset className="rounded border p-3"><legend>Crédito internacional — próprio exercício</legend><p>Prepare a inclusão do imposto estrangeiro comprovado e dos limites documentados, antes do PEC. O resumo anterior de créditos e os históricos ficam conservados. A escolha do país não confirma uma convenção ou benefício.</p><button type="button" onClick={() => save(prepareInternationalBook(book))}>Preparar crédito internacional</button></fieldset>;
|
||||
const update = (next: InternationalCredits) => save({...book, internationalCredits: next});
|
||||
const positionChange = (p: InternationalPosition) => update({...c, positions: c.positions.map(x => x.id === p.id ? p : x)});
|
||||
return <fieldset className="space-y-3 rounded border p-3"><legend>Crédito por dupla tributação internacional</legend>
|
||||
<p>{internationalCreditSource}</p>
|
||||
<p>Reúna os comprovativos de cada país numa posição por exercício. Indique a fracção nacional e a convenção conforme o suporte do caso. Limites admitem decimal ou fracção exacta n/d; a apresentação não constitui arredondamento fiscal. Valores MZN exigem correspondência documental; uma previsão de câmbio ou de pagamento não comprova o crédito.</p>
|
||||
<label className="block">Estado internacional<select aria-label="Estado do crédito internacional L8" value={c.state} onChange={e => update({...c, state: e.target.value as InternationalCredits['state']})}><option value="">Informação em falta</option><option value="known">Posições conhecidas</option><option value="absent">Ausência declarada</option></select></label>
|
||||
<label className="block">Suporte do inventário<input aria-label="Suporte do crédito internacional L8" value={c.reference} onChange={e => update({...c, reference: e.target.value})}/></label>
|
||||
{book.economicCredits ? <p>Resumo económico anterior conservado: {c.economic.state || "Informação em falta"}; {c.economic.amount || "Montante não preenchido"}. Nesta execução é usado o detalhe económico acima.</p> : <Evidence label="Crédito económico — regra própria" value={c.economic} change={economic => update({...c, economic})}/>}
|
||||
<Evidence label="Benefícios/créditos próprios — regra própria" value={c.benefits} change={benefits => update({...c, benefits})}/>
|
||||
<p>{book.economicCredits ? "Benefícios positivos exigem regras próprias; o crédito económico tem o seu detalhe separado. Informação em falta mantém o saldo dependente indeterminado." : "Créditos económicos e benefícios positivos exigem regras próprias; informação em falta mantém o saldo dependente indeterminado."}</p>
|
||||
<button type="button" onClick={() => update({...c, state: 'known', positions: [...c.positions, {...newInternationalPosition(crypto.randomUUID()), subjectNuit: book.subjectNuit, year: book.year, assessmentKey: book.assessmentKey}]})}>Registar posição internacional</button>
|
||||
{c.positions.map((p, index) => {
|
||||
const patch = (v: Partial<InternationalPosition>) => positionChange({...p, ...v});
|
||||
const paymentChange = (r: InternationalTaxProof) => patch({payments: p.payments.map(x => x.id === r.id ? r : x)});
|
||||
return <fieldset key={p.id} className="space-y-2 rounded border p-3"><legend>Posição internacional {index + 1}</legend>
|
||||
<CountrySelect countries={countries} label={'País do imposto estrangeiro ' + (index + 1)} value={p.country} onChange={country => patch({country})} allowLegacyValue/>
|
||||
{([['subjectNuit', 'NUIT titular'], ['year', 'Exercício'], ['incomeAmount', 'Rendimento incluído — MZN'], ['incomeReference', 'Suporte da inclusão na matéria colectável'], ['nationalLimit', 'Fracção nacional correspondente — MZN'], ['nationalLimitReference', 'Suporte da fracção nacional e exercício']] as const).map(([key, label]) => <label key={key} className="block">{label}<input aria-label={'Internacional ' + (index + 1) + ' — ' + label} value={p[key]} onChange={e => patch({[key]: e.target.value})}/></label>)}
|
||||
<label className="block">Rendimento incluído na matéria colectável<select aria-label={'Internacional ' + (index + 1) + ' — Inclusão'} value={p.incomeIncluded} onChange={e => patch({incomeIncluded: e.target.value as InternationalPosition['incomeIncluded']})}><option value="">Informação em falta</option><option value="yes">Sim — documentado</option><option value="no">Não — crédito não aplicável nesta base</option></select></label>
|
||||
<button type="button" onClick={() => patch({assessmentKey: book.assessmentKey, qualificationCountry: p.country})}>Vincular inclusão e limites ao país e à colecta seleccionados</button>
|
||||
<label className="block">Convenção aplicável<select aria-label={'Internacional ' + (index + 1) + ' — Convenção'} value={p.treaty} onChange={e => patch({treaty: e.target.value as InternationalPosition['treaty']})}><option value="">Informação em falta</option><option value="yes">Sim — artigo, período e limite documentados</option><option value="no">Não — ausência de aplicação documentada</option></select></label>
|
||||
{(p.treaty === 'yes' || p.treatyLimit) && <label className="block">Imposto pago elegível nos termos da convenção — limite MZN<input aria-label={'Internacional ' + (index + 1) + ' — Limite CDT'} value={p.treatyLimit} onChange={e => patch({treatyLimit: e.target.value})}/></label>}
|
||||
<label className="block">Suporte da convenção ou da não aplicação<input aria-label={'Internacional ' + (index + 1) + ' — Suporte CDT'} value={p.treatyReference} onChange={e => patch({treatyReference: e.target.value})}/></label>
|
||||
<Evidence label={'Utilização anterior do crédito — país ' + (index + 1)} value={p.previousUse} change={previousUse => patch({previousUse})}/>
|
||||
<p>Utilização anterior é crédito já deduzido e documentado. Não é a soma de simulações históricas nem o imposto pago acima dos limites.</p>
|
||||
<button type="button" onClick={() => patch({payments: [...p.payments, {...newInternationalProof(crypto.randomUUID()), subjectNuit: p.subjectNuit, year: p.year}]})}>Registar imposto estrangeiro na posição {index + 1}</button>
|
||||
{p.payments.map((r, ri) => {
|
||||
const change = (v: Partial<InternationalTaxProof>) => paymentChange({...r, ...v});
|
||||
return <fieldset key={r.id} className="rounded border p-2"><legend>Imposto estrangeiro {index + 1}.{ri + 1}</legend>
|
||||
{([['document', 'Comprovativo'], ['issuer', 'Emitente'], ['date', 'Data (AAAA-MM-DD)'], ['subjectNuit', 'NUIT titular'], ['year', 'Exercício do pagamento'], ['currency', 'Moeda'], ['proofAmount', 'Total comprovado — MZN'], ['amount', 'Parcela de imposto seleccionado — MZN'], ['reference', 'Suporte do pagamento e afectação'], ['taxReference', 'Suporte da natureza de imposto sobre o rendimento']] as const).map(([key, label]) => <label key={key} className="block">{label}<input aria-label={'Imposto estrangeiro ' + (index + 1) + '.' + (ri + 1) + ' — ' + label} value={r[key]} onChange={e => change({[key]: e.target.value})}/></label>)}
|
||||
{(r.exerciseReference || r.date && !r.date.startsWith(p.year + '-')) && <label className="block">Suporte da correspondência da data ao exercício<input aria-label={'Imposto estrangeiro ' + (index + 1) + '.' + (ri + 1) + ' — Correspondência temporal'} value={r.exerciseReference} onChange={e => change({exerciseReference: e.target.value})}/></label>}
|
||||
<label className="block">Pagamento efectivo<select aria-label={'Imposto estrangeiro ' + (index + 1) + '.' + (ri + 1) + ' — Efectivo'} value={r.effective} onChange={e => change({effective: e.target.value as InternationalTaxProof['effective']})}><option value="">Informação em falta</option><option value="yes">Sim — comprovado</option><option value="no">Não — previsão/outra situação</option></select></label>
|
||||
<label className="block">Imposto sobre o rendimento elegível<select aria-label={'Imposto estrangeiro ' + (index + 1) + '.' + (ri + 1) + ' — Natureza'} value={r.incomeTax} onChange={e => change({incomeTax: e.target.value as InternationalTaxProof['incomeTax']})}><option value="">Informação em falta</option><option value="yes">Sim — natureza documentada</option><option value="no">Não — outra natureza, sem crédito presumido</option></select></label>
|
||||
<FiscalProofParcelFields value={r} change={change} label={"Imposto estrangeiro " + (index + 1) + "." + (ri + 1)}/><button type="button" onClick={() => patch({payments: p.payments.filter(x => x.id !== r.id)})}>Retirar imposto estrangeiro {index + 1}.{ri + 1}</button>
|
||||
</fieldset>;
|
||||
})}
|
||||
<button type="button" onClick={() => update({...c, positions: c.positions.filter(x => x.id !== p.id)})}>Retirar posição internacional {index + 1}</button>
|
||||
</fieldset>;
|
||||
})}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {newIrpcInterestRule,type IrpcInterestRule} from '@/lib/irpc-interest';
|
||||
import {mrftaCases,mrftaCaseHtml} from '@/lib/irpc-mrfta-cases';
|
||||
import {useState} from 'react';
|
||||
import {irpcCurrent,type IrpcBook} from '@/lib/irpc-withholdings';
|
||||
import {newMrftaInput,recordMrfta,mrftaHtml,mrftaReportRows,mrftaCurrent,type MrftaInput} from '@/lib/irpc-mrfta';
|
||||
|
||||
export function SimulationIrpcMrfta({draft,book,save}:{draft:Record<string,unknown>;book:IrpcBook;save:(book:IrpcBook)=>void}){
|
||||
const [error,setError]=useState(''),i=book.mrfta??newMrftaInput();
|
||||
const change=(patch:Partial<MrftaInput>)=>save({...book,mrfta:{...i,...patch}});
|
||||
const interestMode=i.interest.mode||(i.interest.calculation==='automatic'?'automatic':'');
|
||||
const interest=(patch:Partial<MrftaInput['interest']>)=>change({interest:{...i.interest,...patch}});
|
||||
const field=(key:'period'|'nuit'|'name'|'commercialName'|'taxArea'|'address'|'contact'|'completenessRef'|'otherRubricsRef',label:string,type='text')=><label className="block">{label}<input className="block rounded border p-2" type={type} value={i[key]} onChange={e=>change({[key]:e.target.value})}/></label>;
|
||||
const select=(key:'declaration'|'timing'|'completeness'|'otherRubrics',label:string,options:Array<[string,string]>)=><label className="block">{label}<select value={i[key]} onChange={e=>change({[key]:e.target.value})}>{options.map(([v,l])=><option key={v} value={v}>{l}</option>)}</select></label>;
|
||||
const periods=[...new Set(book.result?.rows.map(r=>r.period).filter(Boolean)??[])];
|
||||
const identities=Object.entries((JSON.parse(String(draft.modelo_a_snapshot||'{}')).irpcWithholdings??{}) as Record<string,IrpcBook>).filter(([,b])=>b.mrfta?.nuit&&b.mrfta?.name&&(!i.nuit.trim()||i.nuit===b.mrfta.nuit)&&(!i.name.trim()||i.name===b.mrfta.name)&&(['nuit','name','commercialName','taxArea','address','contact'] as const).some(key=>!i[key].trim()&&b.mrfta![key].trim()));
|
||||
return <fieldset aria-label="Preparação MRFTA e juros" className="space-y-3 rounded border p-4"><legend>MRFTA — preenchimento e juros compensatórios</legend><p>Reutiliza o cálculo guardado, documentos e entregas IRPC. Um mês/ano por declaração. A retenção sofrida e as dispensas ficam separadas. Quadros 8/9 reservados à assinatura e aos serviços; não há submissão ou aceitação AT.</p>
|
||||
{identities.map(([scope,b])=><button key={scope} type="button" onClick={()=>{const p:Partial<MrftaInput>={};for(const key of ['nuit','name','commercialName','taxArea','address','contact']as const)if(!i[key].trim())p[key]=b.mrfta![key];change(p);}}>Reutilizar identidade fiscal {b.mrfta!.name} ({scope}) nos campos vazios</button>)}
|
||||
<div className="grid gap-3 md:grid-cols-2">{field('period','Quadro 2 — mês/ano','month')}<label>Período existente<select value="" onChange={e=>{if(e.target.value)change({period:e.target.value});}}><option value="">Seleccione</option>{periods.map(p=><option key={p} value={p}>{p}</option>)}</select></label>{select('declaration','Quadro 1 — retenção na fonte',[['','Por identificar'],['initial','Inicial'],['replacement','Substituição']])}{select('timing','Quadro 2 — situação do prazo',[['','Por esclarecer'],['within','Dentro do prazo'],['late','Fora do prazo']])}{field('nuit','Quadro 3 — NUIT')}{field('taxArea','Quadro 3 — área fiscal')}{field('name','Quadro 4 — nome do sujeito passivo')}{field('commercialName','Quadro 5 — nome comercial')}{field('address','Quadro 6 — domicílio fiscal')}{field('contact','Quadro 6 — contacto')}{select('completeness','Retenções do sujeito passivo neste período conferidas, incluindo demais livros',[['','Por esclarecer'],['yes','Sim'],['no','Ainda não']])}{field('completenessRef','Referência da reconciliação do período')}{select('otherRubrics','IRPS, tributação autónoma e outras rubricas fora deste recorte',[['','Por esclarecer'],['none','Ausência de outros valores devidos documentada'],['pending','Existem ou estão por apurar']])}{field('otherRubricsRef','Documento da situação das outras rubricas')}</div>
|
||||
<h5>Quadro 7 — CER das rubricas correspondidas</h5><p>Base, taxa e imposto vêm do cálculo guardado. O CER é transcrito da referência aplicável; os números de campos da guia antiga não são automaticamente códigos de receita. Códigos introduzidos não são validados por catálogo oficial; códigos provisórios ou de simulação não são correspondências oficiais.</p>
|
||||
{book.result?.inputs.filter(r=>r.direction==='paid'&&book.result?.rows.some(v=>v.id===r.id&&v.period===i.period&&v.status==='calculated'&&v.treatment!=='dispensed')).map(r=>{const code=i.codes.find(c=>c.rowId===r.id)??{rowId:r.id,cer:'',reference:''};const update=(p:Partial<typeof code>)=>change({codes:[...i.codes.filter(c=>c.rowId!==r.id),{...code,...p}]});return <div key={r.id} className="grid gap-2 md:grid-cols-3"><span>{r.documentRef} — {r.counterparty}</span><label>CER<input value={code.cer} onChange={e=>update({cer:e.target.value})}/></label><label>Documento / referência do CER<input value={code.reference} onChange={e=>update({reference:e.target.value})}/></label></div>;})}
|
||||
<h5>Juros compensatórios — separados do principal</h5><p>Regulamento IRPC, artigos 25/35: a taxa refere MAIBOR de 12 meses + 2 pontos. O Banco de Moçambique documenta a descontinuidade da MAIBOR. O portal não adopta MIMO ou Prime como substituto nem calcula juros de mora.</p>
|
||||
<label>Tratamento dos juros<select value={interestMode} onChange={e=>interest(e.target.value==='automatic'?{mode:'',calculation:'automatic'}:{mode:e.target.value as MrftaInput['interest']['mode'],calculation:undefined})}><option value="">Informação insuficiente / cálculo automático por resolver</option><option value="assessment">Transcrever liquidação documentada de juros compensatórios</option><option value="automatic">Cálculo com taxa e regra documentadas — sem parâmetro pré-definido</option><option value="timely">Conferir entrega integral dentro do prazo documentado</option></select></label>
|
||||
{interestMode&&<><label>Período documentado dos juros / entregas<input type="month" value={i.interest.period} onChange={e=>interest({period:e.target.value})}/></label><label>Documento da liquidação / reconciliação<input value={i.interest.reference} onChange={e=>interest({reference:e.target.value})}/></label></>}
|
||||
{['assessment','automatic'].includes(interestMode)&&<>{i.interest.mode==='assessment'&&<label>Montante dos juros em MZN<input value={i.interest.amount} onChange={e=>interest({amount:e.target.value})}/></label>}<label>Data da liquidação<input type="date" value={i.interest.date} onChange={e=>interest({date:e.target.value})}/></label></>}
|
||||
{['timely','automatic'].includes(interestMode)&&<><label>Prazo aplicável documentado<input type="date" value={i.interest.dueDate} onChange={e=>interest({dueDate:e.target.value})}/></label><label>Fonte do prazo, incluindo eventual ajuste de calendário<input value={i.interest.dueReference} onChange={e=>interest({dueReference:e.target.value})}/></label></>}
|
||||
{interestMode==='automatic'&&<fieldset className="space-y-2 rounded border p-2"><legend>Parâmetros documentados do cálculo — sem taxa fiscal de2026 pré-definida</legend><p>Simulação pelos pressupostos introduzidos, sem confirmação fiscal dos juros. Preencha apenas a regra aplicável ao caso. Sem parâmetro completo, os juros continuam indeterminados. A taxa bancária actual não é adoptada por substituição. Imposto por conta não retido e juros de mora mantêm regra própria.</p>{(['annualRate','rateSource','fiscalBasis','validFrom','validTo','dayBasisRef','roundingRef','occurrenceRef']as const).map(k=><label key={k} className="block">{({annualRate:'Taxa anual final (%) documentada, incluindo acréscimos aplicáveis',rateSource:'Documento e citação da taxa',fiscalBasis:'Fundamento da aplicação fiscal ao caso',validFrom:'Parâmetro aplicável desde',validTo:'Parâmetro aplicável até',dayBasisRef:'Documento da base anual e contagem diária',roundingRef:'Documento do arredondamento do total',occurrenceRef:'Documento da retenção e ocorrência'})[k]}<input type={k==='validFrom'||k==='validTo'?'date':'text'} value={i.interest.automatic?.[k]??''} onChange={e=>interest({automatic:{...(i.interest.automatic??newIrpcInterestRule()),[k]:e.target.value}})}/></label>)}{(['dayBasis','rounding','retention']as const).map(k=><label key={k} className="block">{({dayBasis:'Base anual conforme documento',rounding:'Arredondamento do total conforme documento',retention:'Retenção efectuada'})[k]}<select value={i.interest.automatic?.[k]??''} onChange={e=>interest({automatic:{...(i.interest.automatic??newIrpcInterestRule()),[k]:e.target.value} as IrpcInterestRule})}>{(k==='dayBasis'?[['','Por identificar'],['365','365 dias'],['366','366 dias'],['actual_year','Dias efectivos de cada ano']]:k==='rounding'?[['','Por identificar'],['total_cent_half_up','Total aos cêntimos — metade por excesso'],['total_cent_down','Total aos cêntimos — por defeito']]:[['','Por identificar'],['withheld','Sim, documentada'],['not_withheld','Não efectuada, documentado']]).map(([v,l])=><option key={v} value={v}>{l}</option>)}</select></label>)}</fieldset>}
|
||||
<button type="button" className="rounded border p-2" onClick={()=>{try{save(recordMrfta(book,irpcCurrent(draft,book)));setError('');}catch(e){setError(String(e));}}}>Preparar e guardar MRFTA</button><button type="button" className="rounded border p-2" onClick={()=>{try{const html=mrftaHtml(book,irpcCurrent(draft,book)),u=URL.createObjectURL(new Blob([html],{type:'text/html;charset=utf-8'})),a=document.createElement('a');a.href=u;a.download='MRFTA-'+(i.period||'periodo')+'-preparacao.html';a.click();URL.revokeObjectURL(u);setError('');}catch(e){setError(String(e));}}}>Exportar preenchimento MRFTA</button>
|
||||
{book.mrftaResult&&<fieldset className="space-y-2 rounded border p-2"><legend>Casos de trabalho separados por período, rubrica, taxa e CER</legend><p>Estas fichas conservam as pendências do período. Não são declarações autónomas, anexos oficiais ou uma autorização para fundir taxas.</p>{mrftaCases(book.mrftaResult.model).map(c=><div key={c.key}><span>{c.period}; {c.rubric}; {c.rate}%; CER:{c.cer||'por corresponder'}; documentos:{c.documents.join('; ')}.</span><button type="button" onClick={()=>{try{if(!irpcCurrent(draft,book)||!mrftaCurrent(book,true))throw Error('Reprepare as entradas actuais antes de exportar o caso.');const html=mrftaCaseHtml(book.mrftaResult!.model,c.key),u=URL.createObjectURL(new Blob([html],{type:'text/html;charset=utf-8'})),a=document.createElement('a');a.href=u;a.download='MRFTA-caso-'+c.key+'.html';a.click();URL.revokeObjectURL(u);setError('');}catch(e){setError(String(e));}}}>Exportar ficha de trabalho {c.rate}% / {c.cer||'CER pendente'}</button></div>)}</fieldset>}
|
||||
{error&&<p role="alert">{error}</p>}{mrftaReportRows(book,irpcCurrent(draft,book)).map((r,n)=><p key={n}><strong>{r[1]}</strong>: {r[2]}<small>{r[3]}</small></p>)}
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {SimulationIrpcSpecial} from '@/components/simulation-irpc-special';
|
||||
import {SimulationIrpcFormPreparation} from '@/components/simulation-irpc-form-preparation';
|
||||
import {SimulationIrpcPeriodFx} from '@/components/simulation-irpc-period-fx';
|
||||
import {SimulationIrpcDepreciation} from '@/components/simulation-irpc-depreciation';
|
||||
import {SimulationIrpcCosts} from '@/components/simulation-irpc-costs';
|
||||
import {SimulationIrpcSchedules} from '@/components/simulation-irpc-schedules';
|
||||
import {SimulationIrpcAssessment} from '@/components/simulation-irpc-assessment';
|
||||
import {useState} from 'react';
|
||||
import {SimulationIrpcTaxableBase} from '@/components/simulation-irpc-taxable-base';
|
||||
import {newNormalProfitInput,profitTopics,type NormalProfitInput,type ProfitEvidence} from '@/lib/irpc-normal-profit';
|
||||
import {normalBook,saveNormalBook,normalFinancialSource,newNormalAdjustment,recordNormal,normalHtml,normalReportRows,type NormalBook,type NormalAdjustment} from '@/lib/irpc-normal';
|
||||
|
||||
export function SimulationIrpcNormal({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
|
||||
const[error,setError]=useState('');if(draft.fiscal_component!=='irpc')return null;
|
||||
try{
|
||||
const b=normalBook(draft),source=normalFinancialSource(draft);
|
||||
const save=(next:NormalBook)=>{try{onChange('modelo_a_snapshot',saveNormalBook(draft,next));setError('');}catch(e){setError(String(e));}};
|
||||
const patch=(id:string,p:Partial<NormalAdjustment>)=>save({...b,rows:b.rows.map(r=>r.id===id?{...r,...p}:r)});
|
||||
const profit=(patch:Partial<NormalProfitInput>)=>save({...b,profit:{...(b.profit??newNormalProfitInput()),...patch}});
|
||||
const field=(r:NormalAdjustment,k:'document'|'line'|'amount'|'reference',label:string)=><label className="block">{label}<input value={r[k]} onChange={e=>patch(r.id,{[k]:e.target.value})}/></label>;
|
||||
return<section aria-label="IRPC normal — ajustamentos" className="space-y-3 rounded border p-4"><h4>L6 — IRPC normal: ajustamentos ao resultado financeiro</h4><p>Acréscimos de multas sem origem contratual e juros compensatórios incluídos nos custos do plano, artigo 36(1)c. A conferência anual pode determinar o lucro tributável simulado no recorte ordinário documentado. A matéria colectável exige conferência específica de deduções e perdas; a colecta tem cálculo próprio e o saldo final a pagar ou recuperar pertence aL8. Não reutiliza automaticamente juros simulados de L5.</p>{error&&<p role="alert">{error}</p>}
|
||||
{source?<p>Origem: plano financeiro desta oportunidade; {source.periods} meses; {source.currency}; resultado antes de imposto: {source.accountingProfitBeforeTax}. Cobranças, pagamentos e reembolso de capital ficam fora do resultado.</p>:<p>{b.periodsFx?'Complete a correspondência do período e moeda abaixo para reutilizar o plano existente emMZN.':'Prepare o plano financeiro para reutilizar o resultado e os custos reconciliados.'}</p>}
|
||||
<label className="block">Exercício fiscal<input value={b.year} onChange={e=>save({...b,year:e.target.value})}/></label>
|
||||
<label className="block">{b.periodsFx?'Correspondência do plano ao período documentado':b.schedules?'Correspondência dos12 meses do plano ao período anual documentado':'Correspondência dos 12 meses do plano a Janeiro–Dezembro de 2026'}<input value={b.mappingReference} onChange={e=>save({...b,mappingReference:e.target.value})}/></label>
|
||||
<label className="block">Âmbito<select value={b.scope} onChange={e=>save({...b,scope:e.target.value as NormalBook['scope']})}><option value="">Por identificar</option><option value="resident_general">Sujeito residente — regime geral, fora de transparência e regimes especiais</option><option value="ep_general">Não residente com estabelecimento estável — regime geral, só resultado imputável ao EP (mapas complementares)</option><option value="other">Outro âmbito — por corresponder</option></select></label>
|
||||
<label className="block">Suporte do âmbito e da imputação integral do resultado ao sujeito/EP<input value={b.scopeReference} onChange={e=>save({...b,scopeReference:e.target.value})}/></label>
|
||||
<button type="button" onClick={()=>save({...b,rows:[...b.rows,newNormalAdjustment(crypto.randomUUID())]})}>Acrescentar ajustamento ao resultado</button>
|
||||
{b.rows.map(r=><fieldset key={r.id} className="space-y-2 rounded border p-3"><legend>{r.document||'Ajustamento por identificar'}</legend><label className="block">Custo de origem na ponte<select value={r.sourceCode} onChange={e=>patch(r.id,{sourceCode:e.target.value})}><option value="">Seleccione</option>{source?.accountingLines.filter(l=>l.accountingEffect==='expense').map(l=><option key={l.code} value={l.code}>{l.origin}: {l.total} {source.currency}</option>)}</select></label>{field(r,'document','Documento ou pressuposto identificado')}{field(r,'line','Linha distinta do documento')}{field(r,'amount','Parcela do custo em MZN')}
|
||||
<label className="block">Natureza documentada<select value={r.rule} onChange={e=>patch(r.id,{rule:e.target.value as NormalAdjustment['rule']})}><option value="">Por classificar</option><option value="noncontractual_fine">Multa/encargo por infracção sem origem contratual — 36(1)c</option><option value="compensatory_interest">Juros compensatórios — 36(1)c</option><option value="other">Outro ajustamento — por corresponder</option></select></label>
|
||||
<label className="block">Parcela incluída nos custos do resultado de partida<select value={r.included} onChange={e=>patch(r.id,{included:e.target.value as NormalAdjustment['included']})}><option value="">Por demonstrar</option><option value="yes">Sim, reconciliada com a linha de origem</option><option value="no">Não — não acrescentar novamente</option></select></label>{field(r,'reference','Suporte da qualificação, ocorrência e inclusão contabilística')}<p>Juros bancários e penalidades contratuais não são classificados automaticamente como infracções.</p></fieldset>)}
|
||||
{!b.profit?<button type="button" onClick={()=>profit({})}>Iniciar conferência até ao lucro tributável</button>:<fieldset aria-label="Conferência do lucro tributável" className="space-y-3 rounded border p-3"><legend>Resultado financeiro → ajustamentos → lucro tributável simulado</legend><p>Conferência do sujeito passivo/exercício completo. Cada receita remanescente deve ser ordinária do exercício;cada custo remanescente deve estar documentado e ser indispensável à fonte produtora,sem outras exclusões ou limites. Outras situações conhecidas permanecem discriminadas. Ausência declarada exige suporte e não é informação em falta.</p>
|
||||
<label className="block">Suporte da completude do sujeito passivo no exercício<input value={b.profit.completenessReference} onChange={e=>profit({completenessReference:e.target.value})}/></label>
|
||||
{source?.accountingLines.map(l=>{const review=b.profit!.lines.find(r=>r.code===l.code)??{code:l.code,state:'' as const,reference:''};const change=(p:Partial<typeof review>)=>profit({lines:[...b.profit!.lines.filter(r=>r.code!==l.code),{...review,...p}]});return<fieldset key={l.code} className="rounded border p-2"><legend>{l.origin} — {l.total} {source.currency}</legend><label className="block">Estado fiscal — {l.origin}<select aria-label={'Estado fiscal — '+l.origin} value={review.state} onChange={e=>change({state:e.target.value as ProfitEvidence['state']})}><option value="">Informação em falta</option><option value="known">Valor conhecido e qualificação ordinária documentada</option><option value="absent">Ausência declarada — conferir origem zero</option></select></label><label className="block">Suporte fiscal — {l.origin}<input value={review.reference} onChange={e=>change({reference:e.target.value})}/></label></fieldset>;})}
|
||||
<p>Depreciação positiva e juros financeiros não integralmente acrescidos exigem correspondência própria;uma declaração de ausência não apaga os valores do plano.</p>
|
||||
{profitTopics.map(([k,label])=>{const t=b.profit!.topics[k];const change=(p:Partial<ProfitEvidence>)=>profit({topics:{...b.profit!.topics,[k]:{...t,...p}}});return<fieldset key={k} className="rounded border p-2"><legend>{label}</legend><label className="block">Estado — {label}<select aria-label={'Estado — '+label} value={t.state} onChange={e=>change({state:e.target.value as ProfitEvidence['state']})}><option value="">Informação em falta</option><option value="known">Valor conhecido</option><option value="absent">Ausência declarada com suporte</option></select></label>{(t.state==='known'||t.amount!=='')&&<label className="block">Montante conhecido — {label}<input value={t.amount} onChange={e=>change({amount:e.target.value})}/></label>}<label className="block">Suporte — {label}<input value={t.reference} onChange={e=>change({reference:e.target.value})}/></label></fieldset>;})}
|
||||
</fieldset>}
|
||||
<SimulationIrpcPeriodFx draft={draft} book={b} save={save}/>
|
||||
<SimulationIrpcSchedules book={b} save={save}/>
|
||||
<SimulationIrpcDepreciation draft={draft} book={b} save={save}/>
|
||||
<SimulationIrpcCosts draft={draft} book={b} save={save}/>
|
||||
<SimulationIrpcTaxableBase draft={draft} book={b} save={save}/>
|
||||
<SimulationIrpcAssessment book={b} save={save}/>
|
||||
<SimulationIrpcSpecial draft={draft} book={b} save={save}/>
|
||||
<SimulationIrpcFormPreparation draft={draft} book={b} save={save}/>
|
||||
<button type="button" onClick={()=>{try{save(recordNormal(draft,b));}catch(e){setError(String(e));}}}>Calcular e guardar L6 e tratamentos L7 preparados</button>
|
||||
<button type="button" onClick={()=>{try{const html=normalHtml(draft),url=URL.createObjectURL(new Blob([html],{type:'text/html;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download='IRPC-normal-ajustamentos.html';a.click();URL.revokeObjectURL(url);setError('');}catch(e){setError(String(e));}}}>Exportar mapa parcial L6</button>
|
||||
{normalReportRows(draft).map((r,n)=><p key={n}>{r[1]} — {r[2]} — {r[3]}</p>)}
|
||||
</section>;
|
||||
}catch(e){return<p role="alert">Preparação L6 não recuperável: {String(e)}. Originais e histórico conservados.</p>;}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {FiscalProofParcelFields} from './fiscal-proof-parcel';
|
||||
import {newPecPayment, pecSource, type PecPayment} from '@/lib/irpc-pec';
|
||||
import type {AnnualBook} from '@/lib/irpc-annual-settlement';
|
||||
|
||||
export function SimulationIrpcPec({book, save}: {book: AnnualBook; save: (book: AnnualBook) => void}) {
|
||||
const detail = book.pecPayments;
|
||||
if (!detail) return <fieldset className="rounded border p-3"><legend>PEC — comprovativos do próprio exercício</legend><p>O resumo anterior fica conservado. Preparar o detalhe permite reconciliar os pagamentos efectivos e deduzi-los até à colecta disponível, sem reembolso do excedente PEC.</p><button type="button" onClick={() => save({...book, pecPayments: {version: 1, state: book.pec.state, reference: book.pec.reference, rows: []}})}>Preparar detalhe PEC</button></fieldset>;
|
||||
const update = (next: typeof detail) => save({...book, pecPayments: next});
|
||||
return <fieldset className="space-y-2 rounded border p-3"><legend>PEC — pagamentos efectivamente comprovados</legend>
|
||||
<p>{pecSource}</p><p>Identifique a natureza PEC no comprovativo. As utilizações anteriores exigem valor e suporte; zero conhecido é diferente de informação em falta. A parcela seleccionada não pode exceder o comprovado menos o já utilizado. Saldos de anos anteriores, cálculo da obrigação e créditos próprios permanecem separados.</p>
|
||||
<label className="block">Estado dos PEC<select aria-label="Estado dos PEC comprovados L8" value={detail.state} onChange={e => update({...detail, state: e.target.value as typeof detail.state})}><option value="">Informação em falta</option><option value="known">Pagamentos conhecidos</option><option value="absent">Ausência declarada com suporte</option></select></label>
|
||||
<label className="block">Suporte do inventário PEC<input aria-label="Suporte do inventário PEC L8" value={detail.reference} onChange={e => update({...detail, reference: e.target.value})}/></label>
|
||||
<button type="button" onClick={() => update({...detail, state: 'known', rows: [...detail.rows, {...newPecPayment(crypto.randomUUID()), subjectNuit: book.subjectNuit, year: book.year}]})}>Registar comprovativo PEC</button>
|
||||
{detail.rows.map((r, index) => {
|
||||
const change = (p: Partial<PecPayment>) => update({...detail, rows: detail.rows.map(x => x.id === r.id ? {...x, ...p} : x)});
|
||||
return <fieldset key={r.id} className="rounded border p-2"><legend>PEC {index + 1}</legend>
|
||||
{([['document', 'Comprovativo PEC'], ['issuer', 'Emitente'], ['date', 'Data (AAAA-MM-DD)'], ['subjectNuit', 'NUIT beneficiário'], ['year', 'Exercício de origem'], ['currency', 'Moeda'], ['proofAmount', 'Montante comprovado'], ['previouslyUsed', 'Já utilizado fora destas parcelas'], ['priorUseReference', 'Suporte das utilizações anteriores ou do zero conhecido'], ['amount', 'Parcela disponível seleccionada'], ['reference', 'Suporte do pagamento PEC e afectação']] as const).map(([key, label]) => <label key={key} className="block">{label}<input aria-label={'PEC L8 ' + (index + 1) + ' — ' + label} value={r[key]} onChange={e => change({[key]: e.target.value})}/></label>)}
|
||||
<label className="block">Pagamento PEC efectivamente comprovado<select aria-label={'PEC L8 ' + (index + 1) + ' — Efectivo'} value={r.effective} onChange={e => change({effective: e.target.value as PecPayment['effective']})}><option value="">Informação em falta</option><option value="yes">Sim — natureza PEC e pagamento reconciliados</option><option value="no">Não — previsão ou outra natureza</option></select></label>
|
||||
<FiscalProofParcelFields value={r} change={change} label={"PEC L8 " + (index + 1)}/><button type="button" onClick={() => update({...detail, rows: detail.rows.filter(x => x.id !== r.id)})}>Retirar selecção PEC {index + 1}</button>
|
||||
</fieldset>;
|
||||
})}
|
||||
<p>O detalhe substitui o resumo PEC apenas nesta execução. Os comprovativos e resultados de execuções anteriores permanecem no histórico; não há consumo automático de saldos oficiais.</p>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {SimulationFxSelection} from '@/components/simulation-fx-selection';
|
||||
import {rawNormalFinancialSource,type NormalBook} from '@/lib/irpc-normal';
|
||||
import {newNormalPeriodFx,mapNormalPeriodFx,type NormalPeriodFxInput} from '@/lib/irpc-normal-period-fx';
|
||||
import {irpcFxSources,irpcFxRow,updateIrpcFx} from '@/lib/irpc-fx';
|
||||
export function SimulationIrpcPeriodFx({draft,book,save}:{draft:Record<string,unknown>;book:NormalBook;save:(b:NormalBook)=>void}){
|
||||
const raw=rawNormalFinancialSource(draft),i=book.periodsFx;
|
||||
if(!i)return<button type="button" onClick={()=>save({...book,periodsFx:newNormalPeriodFx()})}>Preparar período curto ou plano noutra moeda</button>;
|
||||
const patch=(p:Partial<NormalPeriodFxInput>)=>save({...book,periodsFx:{...i,...p}}),model=mapNormalPeriodFx(draft,i,raw),sources=irpcFxSources(draft);
|
||||
const text=(k:'from'|'to'|'planStart'|'reference'|'conversionReference'|'mznMapsReference',label:string)=><label className="block">{label}<input value={i[k]} onChange={e=>patch({[k]:e.target.value})}/></label>;
|
||||
return<fieldset aria-label="Período e moeda L6" className="space-y-3 rounded border p-3"><legend>Período fiscal e conversão do plano — simulação L6</legend><p>Reutiliza os meses e valores originais do plano. Seleccione apenas os meses do período documentado;sem anualizar o resultado. Os mapas,activos e saldos fiscais permanecem emMZN e exigem reconciliação explícita. A conversão cambial para simulação usa o mecanismo existente; não confirma automaticamente a regra fiscal de conversão anual.</p>
|
||||
<label className="block">Tipo de período<select value={i.kind} onChange={e=>patch({kind:e.target.value as NormalPeriodFxInput['kind']})}><option value="">Por identificar</option><option value="annual">Anual documentado — 12meses</option><option value="start">Início de actividade — 7(4)a</option><option value="cessation">Cessação de actividade — 7(4)b</option><option value="same_year">Início e fim de sujeição no mesmo exercício — 7(4)c</option><option value="transition">Transição para período autorizado/comunicado — 7(4)d</option></select></label>
|
||||
{text('from','Início do período fiscal (AAAA-MM-DD)')}{text('to','Fim do período fiscal (AAAA-MM-DD)')}{text('planStart','Primeiro mês civil do plano (AAAA-MM-01)')}{text('reference','Suporte do evento,período e correspondência dos meses')}
|
||||
{book.schedules&&<button type="button" onClick={()=>patch({from:i.from||book.schedules!.period.from,to:i.to||book.schedules!.period.to,reference:i.reference||book.schedules!.period.reference})}>Reutilizar datas e suporte já declarados nos campos vazios</button>}
|
||||
{text('mznMapsReference','Reconciliação dos mapas,parcelas,activos e saldos existentes em MZN para este período')}
|
||||
{raw?.currency!=='MZN'&&<>{text('conversionReference','Pressuposto documentado da conversão mensal para simulação')}
|
||||
{[...new Set(model.rows.map(r=>r.period))].map(period=>{const date=model.rows.find(r=>r.period===period)?.date,entry=i.months.find(m=>m.period===period)??{period,currency:raw?.currency??''};const change=(p:Partial<typeof entry>)=>patch({months:[...i.months.filter(m=>m.period!==period),{...entry,...p}]});return<fieldset key={period} aria-label={'Câmbio do mês '+period} className="rounded border p-2"><legend>Mês{period} — {date} — {raw?.currency}</legend><label>Reutilizar selecção cambial existente<select value={entry.fxLink?.id??''} onChange={e=>{const s=sources.find(s=>s.id===e.target.value);if(s)change({currency:raw?.currency??'',fxSelection:s.selection,fxLink:{id:s.id,fingerprint:s.fingerprint}});}}><option value="">Seleccione uma origem documentada</option>{sources.map(s=><option value={s.id} key={s.id}>{s.label}</option>)}</select></label><SimulationFxSelection row={irpcFxRow('normal-'+period,entry)} onChange={p=>{if(p.fxSelection)change({...updateIrpcFx(entry,p.fxSelection),currency:raw?.currency??''});}}/></fieldset>;})}
|
||||
</>}
|
||||
{i.months.filter(m=>!model.rows.some(r=>r.period===m.period)).map(m=><p key={m.period}>Selecção conservada do mês{m.period},fora do período actual. <button type="button" onClick={()=>patch({months:i.months.filter(x=>x.period!==m.period)})}>Retirar selecção do mês{m.period} deste mapa</button></p>)}
|
||||
<p>{model.months}meses;resultado convertido:{model.source?.accountingProfitBeforeTax??'Indeterminado'} MZN. {model.pending.join(' | ')}</p><p>Meses parciais,limites anuais por corresponder e contagem de perdas41(5) afectam apenas os respectivos casos. Colecta não equivale ao saldo final a pagar.</p>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {newNormalSchedules,newScheduleRow,newNormalAsset,scheduleRules,type NormalSchedules,type ScheduleRow,type NormalAsset} from '@/lib/irpc-normal-schedules';
|
||||
import type {NormalBook} from '@/lib/irpc-normal';
|
||||
export function SimulationIrpcSchedules({book,save}:{book:NormalBook;save:(b:NormalBook)=>void}){
|
||||
const s=book.schedules;if(!s)return<button type="button" onClick={()=>save({...book,schedules:newNormalSchedules()})}>Iniciar mapas complementares L6</button>;
|
||||
const patch=(p:Partial<NormalSchedules>)=>save({...book,schedules:{...s,...p}});
|
||||
const row=(id:string,p:Partial<ScheduleRow>)=>patch({rows:s.rows.map(r=>r.id===id?{...r,...p}:r)});
|
||||
const asset=(id:string,p:Partial<NormalAsset>)=>patch({assets:s.assets.map(a=>a.id===id?{...a,...p}:a)});
|
||||
return<fieldset aria-label="Mapas complementares L6" className="space-y-3 rounded border p-3"><legend>Mapas complementares — regras ordinárias documentadas</legend>
|
||||
<p>Reutiliza o resultado e as linhas do plano. As somas brutas de cada tema devem corresponder à conferência do lucro tributável. Os efeitos com sinal ficam discriminados; uma dúvida não apaga as parcelas independentes. L7 conserva benefícios e casos especiais; L8 conserva o fecho anual.</p>
|
||||
{book.periodsFx&&book.periodsFx.kind!=='annual'?<p>Período actual no mapa «Período fiscal e conversão do plano». O período anual anterior permanece conservado no histórico.</p>:<fieldset><legend>Período anual e correspondência dos meses</legend><label>Tipo de período<select aria-label="Tipo de período L6" value={s.period.kind} onChange={e=>patch({period:{...s.period,kind:e.target.value as NormalSchedules['period']['kind']}})}><option value="">Por identificar</option><option value="calendar">Ano civil — artigo7(1)</option><option value="authorised_annual">Anual diferente — autorização artigo7(2)</option><option value="ep_annual">Anual diferente de EP — comunicação artigo7(3)</option></select></label>{(['from','to','reference']as const).map(k=><label key={k} className="block">{({from:'Início do período AAAA-MM-DD',to:'Termo do período AAAA-MM-DD',reference:'Suporte do período e da correspondência mensal'})[k]}<input aria-label={'Período L6 '+k} value={s.period[k]} onChange={e=>patch({period:{...s.period,[k]:e.target.value}})}/></label>)}<p>Período anual diferente: documente autorização do artigo7(2) ou comunicação do EP do artigo7(3), início de aplicação e manutenção por cinco exercícios. O plano representa o período completo com termo em2026, emMZN. Períodos curtos e conversão fiscal anual de outra moeda ficam por corresponder.</p></fieldset>}
|
||||
<button type="button" onClick={()=>patch({rows:[...s.rows,newScheduleRow(crypto.randomUUID())]})}>Acrescentar regra complementar</button>
|
||||
{s.rows.map((r,n)=><fieldset key={r.id} className="rounded border p-2"><legend>Regra complementar {n+1}</legend>
|
||||
<label>Tratamento<select aria-label={'Tratamento complementar '+(n+1)} value={r.rule} onChange={e=>row(r.id,{rule:e.target.value as ScheduleRow['rule']})}><option value="">Por identificar</option>{Object.entries(scheduleRules).map(([k,v])=><option key={k} value={k}>{v[0]} — {v[2]}</option>)}</select></label>
|
||||
{([['amount','Montante MZN'],['sourceCode','Código da linha de origem (custos/proveitos já incluídos)'],['document','Documento'],['line','Linha distinta'],['reference','Suporte da regra e dos factos'],['fiscalYear','Exercício de imputação (periodização)']]as const).map(([k,label])=><label key={k} className="block">{label}<input aria-label={label+' complementar '+(n+1)} value={r[k]} onChange={e=>row(r.id,{[k]:e.target.value})}/></label>)}
|
||||
<p>Entradas/distribuições de capital e variações patrimoniais são externas ao resultado. Variação negativa ordinária: indispensável à actividade sujeita, sem liberalidade, menos-valia latente ou distribuição; sem gratificações especiais. Periodização: operação ordinária e exercício de realização documentados; sem obra plurianual ou correcção de exercício anterior.</p>
|
||||
<label>Condições demonstradas<select aria-label={'Condições complementar '+(n+1)} value={r.conditions} onChange={e=>row(r.id,{conditions:e.target.value as ''|'yes'})}><option value="">Informação em falta</option><option value="yes">Sim, nos documentos identificados</option></select></label>
|
||||
<button type="button" onClick={()=>patch({rows:s.rows.filter(q=>q.id!==r.id)})}>Retirar regra {n+1} das entradas actuais</button></fieldset>)}
|
||||
<fieldset><legend>Juros financeiros ordinários — artigo22c</legend><p>Somente capitais alheios aplicados na exploração, encargos documentados, não capitalizáveis, sem partes relacionadas, subcapitalização ou outra limitação especial. Identifique o remanescente após os juros compensatórios acrescidos no mapa anterior. Outros juros permanecem pendentes neste caso.</p>
|
||||
<label className="block">Juros ordinários remanescentes MZN<input value={s.interest.amount} onChange={e=>patch({interest:{...s.interest,amount:e.target.value}})}/></label>
|
||||
<label className="block">Suporte dos juros e condições<input value={s.interest.reference} onChange={e=>patch({interest:{...s.interest,reference:e.target.value}})}/></label>
|
||||
<label>Condições dos juros<select aria-label="Condições dos juros ordinários" value={s.interest.conditions} onChange={e=>patch({interest:{...s.interest,conditions:e.target.value as ''|'yes'}})}><option value="">Informação em falta</option><option value="yes">Demonstradas no recorte ordinário</option></select></label></fieldset>
|
||||
{!book.depreciation?<fieldset><legend>Amortizações fiscais — quotas constantes anuais</legend><p>Decreto72/2013, artigos3–7. Activo próprio sujeito a deperecimento, custo histórico, uso no início de2026, método uniforme, vida útil fiscal não excedida. Documente que não há regra sectorial específica prevalecente ou limitação especial. A categoria exige qualificação documentada; os itens genéricos9/9.1 só se aplicam quando não há taxa específica da tabelaI. Duodécimos, reavaliação, bens usados e recuperação de quotas antes tributadas ficam separados.</p>
|
||||
<label className="block">Reconciliação de todos os activos com a depreciação do plano<input value={s.assetsReference} onChange={e=>patch({assetsReference:e.target.value})}/></label>
|
||||
<button type="button" onClick={()=>patch({assets:[...s.assets,newNormalAsset(crypto.randomUUID())]})}>Acrescentar activo fiscal</button>
|
||||
{s.assets.map((a,n)=><fieldset key={a.id} className="rounded border p-2"><legend>Activo {n+1}</legend><label>Categoria fiscal<select aria-label={'Categoria activo '+(n+1)} value={a.category} onChange={e=>asset(a.id,{category:e.target.value as NormalAsset['category'],rate:e.target.value==='computer'?'25':e.target.value==='server'?'20':'',tableReference:e.target.value==='computer'?'Decreto72/2013 tabelaII grupo3 item9':e.target.value==='server'?'Decreto72/2013 tabelaII grupo3 item9.1':''})}><option value="">Por identificar</option><option value="computer">Computador, impressora, UPS — II/3/9 — 25%</option><option value="server">Servidor — II/3/9.1 — 20%</option><option value="other">Outra categoria — correspondência pendente</option></select></label>{([['description','Descrição'],['cost','Custo fiscal documentado MZN'],['residual','Valor residual MZN'],['rate','Taxa anual documentada %'],['accounting','Depreciação contabilística de2026 MZN'],['previousFiscal','Amortização fiscal acumulada anterior MZN'],['inUseFrom','Data de entrada em uso AAAA-MM-DD'],['tableReference','Diploma, tabela, código e taxa aplicáveis'],['reference','Documentos e condições do activo']]as const).map(([k,label])=><label key={k} className="block">{label}<input aria-label={label+' activo '+(n+1)} value={a[k]} onChange={e=>asset(a.id,{[k]:e.target.value})}/></label>)}
|
||||
<label>Condições do activo<select aria-label={'Condições activo '+(n+1)} value={a.conditions} onChange={e=>asset(a.id,{conditions:e.target.value as ''|'yes'})}><option value="">Informação em falta</option><option value="yes">Demonstradas no recorte anual constante</option></select></label><button type="button" onClick={()=>patch({assets:s.assets.filter(q=>q.id!==a.id)})}>Retirar activo {n+1} das entradas actuais</button></fieldset>)}</fieldset>:<p>Activos do mapa inicial conservados; a depreciação é agora reconciliada exclusivamente pelo mapa de tabelas, métodos, duodécimos e quotas anteriores abaixo.</p>}
|
||||
<label className="block">Suporte da perda fiscal do próprio exercício, se apurada<input value={s.currentLossReference} onChange={e=>patch({currentLossReference:e.target.value})}/></label><p>Perda actual conhecida: matéria colectável e colecta zero apenas depois de conferidas as restantes condições. Não é dedução a si própria nem saldo oficial de reporte. Informação insuficiente mantém os totais dependentes indeterminados.</p>
|
||||
</fieldset>;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,30 @@
|
||||
import {irpcBook} from '@/lib/irpc-withholdings';
|
||||
import {newNormalBaseInput,newNormalLoss,type NormalBaseInput,type NormalLoss} from '@/lib/irpc-normal-base';
|
||||
import type {NormalBook} from '@/lib/irpc-normal';
|
||||
|
||||
export function SimulationIrpcTaxableBase({draft,book,save}:{draft:Record<string,unknown>;book:NormalBook;save:(b:NormalBook)=>void}){
|
||||
if(!book.profit)return null;
|
||||
const b=book.base;const change=(p:Partial<NormalBaseInput>)=>save({...book,base:{...(b??newNormalBaseInput()),...p}});
|
||||
let identity:{nuit:string;name:string}|null=null;try{const m=irpcBook(draft).mrfta;if(m&&/^\d{9}$/.test(m.nuit))identity={nuit:m.nuit,name:m.name};}catch{/* A missing withholding book does not block this independent path. */}
|
||||
if(!b)return<button type="button" onClick={()=>change({})}>Iniciar determinação da matéria colectável</button>;
|
||||
const loss=(id:string,p:Partial<NormalLoss>)=>change({losses:b.losses.map(l=>l.id===id?{...l,...p}:l)});
|
||||
const choices:Array<[string,string]>=[['','Por esclarecer'],['yes','Sim, documentado'],['no','Não, documentado']];
|
||||
return<fieldset aria-label="Matéria colectável — perdas e deduções" className="space-y-3 rounded border p-3"><legend>Lucro tributável → matéria colectável simulada</legend><p>Reutiliza o lucro tributável do cálculo actual, sem introduzir novamente os montantes. Esse lucro e a matéria colectável não são imposto a pagar. Recorte: regime geral, método declarativo, perdas fiscais próprias em exercícios anuais ordinários, com saldo e utilização propostos expressos. Não aplica benefícios positivos sem regra específica nem créditos de retenção.</p>
|
||||
{identity&&(!b.subjectNuit||b.subjectNuit===identity.nuit)&&<button type="button" onClick={()=>{if(!b.subjectNuit||b.subjectNuit===identity!.nuit)change({subjectNuit:identity!.nuit,identityReference:b.identityReference||'Identidade reutilizada da preparação MRFTA desta oportunidade: '+identity!.name});}}>Reutilizar NUIT da preparação existente {identity.nuit}</button>}
|
||||
<label className="block">NUIT do sujeito passivo da matéria colectável<input value={b.subjectNuit} onChange={e=>change({subjectNuit:e.target.value})}/></label>
|
||||
<label className="block">Suporte da identidade e correspondência com o lucro<input value={b.identityReference} onChange={e=>change({identityReference:e.target.value})}/></label>
|
||||
<label className="block">Método de determinação<select aria-label="Método de determinação" value={b.method} onChange={e=>change({method:e.target.value as NormalBaseInput['method']})}><option value="">Por esclarecer</option><option value="direct">Declarativo/directo, documentado</option><option value="indirect">Métodos indirectos — fora deste recorte</option></select></label>
|
||||
<label className="block">Suporte do método de determinação<input value={b.methodReference} onChange={e=>change({methodReference:e.target.value})}/></label>
|
||||
<label className="block">Situação das perdas fiscais anteriores<select aria-label="Situação das perdas fiscais anteriores" value={b.lossesState} onChange={e=>change({lossesState:e.target.value as NormalBaseInput['lossesState']})}><option value="">Informação em falta</option><option value="known">Perdas/saldos conhecidos — discriminar</option><option value="absent">Ausência declarada com suporte</option></select></label>
|
||||
<label className="block">Suporte e completude dos saldos de perdas<input value={b.lossesReference} onChange={e=>change({lossesReference:e.target.value})}/></label>
|
||||
{(b.lossesState==='known'||b.losses.length>0)&&<><button type="button" onClick={()=>change({losses:[...b.losses,{...newNormalLoss(crypto.randomUUID()),subjectNuit:b.subjectNuit}]})}>Acrescentar perda fiscal por exercício</button>
|
||||
<label className="block">Objecto social e natureza da actividade mantidos no termo do exercício<select aria-label="Objecto social e natureza da actividade mantidos no termo do exercício" value={b.unchangedObject} onChange={e=>change({unchangedObject:e.target.value as NormalBaseInput['unchangedObject']})}>{choices.map(([v,l])=><option key={v} value={v}>{l}</option>)}</select></label><label className="block">Suporte da manutenção ou alteração da actividade<input value={b.objectReference} onChange={e=>change({objectReference:e.target.value})}/></label>
|
||||
{b.losses.map((l,n)=><fieldset key={l.id} className="space-y-2 rounded border p-2"><legend>Perda {n+1} — {l.year||'exercício por identificar'}</legend>{(['year','subjectNuit','assessed','previouslyUsed','requested','reference']as const).map(k=><label className="block" key={k}>{({year:'Exercício da perda',subjectNuit:'NUIT da perda',assessed:'Perda fiscal apurada ou corrigida em MZN',previouslyUsed:'Utilizações anteriores em MZN, incluindo zero conhecido',requested:'Utilização proposta neste exercício em MZN',reference:'Documento do apuramento fiscal, correcções e saldo'})[k]}<input value={l[k]} onChange={e=>loss(l.id,{[k]:e.target.value})}/></label>)}
|
||||
<label className="block">Origem da perda<select aria-label={'Origem da perda '+(n+1)} value={l.kind} onChange={e=>loss(l.id,{kind:e.target.value as NormalLoss['kind']})}><option value="">Por esclarecer</option><option value="ordinary">Perda própria ordinária do mesmo sujeito</option><option value="transfer">Transmitida/fusão — tratamento próprio</option><option value="other">Outra origem — por corresponder</option></select></label>
|
||||
{(['annualPeriods','generalActivity','balanceConfirmed']as const).map(k=><label className="block" key={k}>{({annualPeriods:'Exercícios anuais ordinários, sem períodos curtos especiais',generalActivity:'Perda do regime geral, fora de transparência/isenção parcial/actividade reduzida',balanceConfirmed:'Saldo fiscal, correcções e utilizações anteriores reconciliados'})[k]}<select aria-label={k+' '+(n+1)} value={l[k]} onChange={e=>loss(l.id,{[k]:e.target.value as NormalLoss['annualPeriods']})}>{choices.map(([v,label])=><option key={v} value={v}>{label}</option>)}</select></label>)}
|
||||
</fieldset>)}<p>A utilização é escolhida explicitamente. O portal não presume ordem FIFO, não ultrapassa saldo ou lucro e não consome o saldo oficial ao simular ou guardar.</p></>}
|
||||
<label className="block">Benefícios/deduções à matéria colectável<select aria-label="Benefícios/deduções à matéria colectável" value={b.benefits.state} onChange={e=>change({benefits:{...b.benefits,state:e.target.value as NormalBaseInput['benefits']['state']}})}><option value="">Informação em falta</option><option value="known">Valor conhecido — identificar</option><option value="absent">Ausência declarada com suporte</option></select></label>
|
||||
{(b.benefits.state==='known'||b.benefits.amount!=='')&&<label className="block">Montante conhecido de benefícios/deduções em MZN<input value={b.benefits.amount} onChange={e=>change({benefits:{...b.benefits,amount:e.target.value}})}/></label>}
|
||||
<label className="block">Suporte dos benefícios/deduções, regra e exercício<input value={b.benefits.reference} onChange={e=>change({benefits:{...b.benefits,reference:e.target.value}})}/></label><p>Um benefício positivo conhecido permanece por corresponder; a referência não autoriza subtracção livre. Créditos à colecta, retenções sofridas e imposto a pagar seguem etapa própria.</p>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {SimulationIrpcMrfta} from '@/components/simulation-irpc-mrfta';
|
||||
import {irpcReliefRules,newIrpcReliefPreparation,prepareIrpcRelief} from '@/lib/irpc-relief-preparation';
|
||||
import {SimulationFxSelection} from '@/components/simulation-fx-selection';
|
||||
import {irpcFxSources,irpcFxRow,updateIrpcFx,type IrpcFxAmount} from '@/lib/irpc-fx';
|
||||
import {useState} from 'react';
|
||||
import {irpcBook,saveIrpcBook,reuseIrpcSources,irpcSources,newIrpcRow,recordIrpc,irpcKinds,emptyIrpcOrdinaryFacts,irpcReportRows,irpcPreparationHtml,type IrpcBook,type IrpcRow,type IrpcEvent,type IrpcPayment,type IrpcOrdinaryFacts} from '@/lib/irpc-withholdings';
|
||||
export function SimulationIrpcWithholdings({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
|
||||
const[error,setError]=useState('');if(draft.fiscal_component!=='irpc'&&draft.fiscal_component!=='withholding')return null;
|
||||
try{const book=irpcBook(draft),sources=irpcSources(draft),save=(b:IrpcBook)=>{try{onChange('modelo_a_snapshot',saveIrpcBook(draft,b));setError('');}catch(e){setError(String(e));}},change=(id:string,p:Partial<IrpcRow>)=>save({...book,rows:book.rows.map(r=>r.id===id?{...r,...p}:r)}),uid=()=>crypto.randomUUID();
|
||||
const field=(r:IrpcRow,k:keyof IrpcRow,label:string,type='text')=><label className="block">{label}<input className="block rounded border p-2" type={type} value={String(r[k]??'')} onChange={e=>change(r.id,{[k]:e.target.value})}/></label>;
|
||||
const select=(r:IrpcRow,k:keyof IrpcRow,label:string,options:Array<[string,string]>)=><label className="block">{label}<select className="block rounded border p-2" value={String(r[k]??'')} onChange={e=>change(r.id,{[k]:e.target.value})}>{options.map(([v,l])=><option key={v} value={v}>{l}</option>)}</select></label>;
|
||||
const yn:Array<[string,string]>=[['','Por esclarecer'],['yes','Sim'],['no','Não']];
|
||||
const eventLabels:Array<[IrpcEvent['kind'],string]>=[['cost','Reconhecimento do custo'],['payment','Pagamento do rendimento'],['maturity','Vencimento, ainda que presumido'],['availability','Colocação à disposição'],['liquidation','Liquidação'],['quantification','Apuramento do quantitativo']];
|
||||
const fxSources=irpcFxSources(draft);
|
||||
const renderFx=(id:string,input:IrpcFxAmount,apply:(patch:Partial<IrpcFxAmount>)=>void)=><div className="space-y-2"><p>A base/comprovativo conserva a moeda e o montante originais. A conversão reutiliza o selector e a aritmética entregues; a data cambial fica distinta do evento da retenção.</p>{fxSources.map(src=><div key={src.id}><span>{src.label};data:{src.selection.applied?.date||'por confirmar'};selecção actual:{input.fxSelection?.applied?.rate||'nenhuma'}.</span><button type="button" className="rounded border p-2" onClick={()=>{if(window.confirm(`Substituir a selecção cambial desta linha (${input.fxSelection?.applied?.rate||'vazia'}) pela origem ${src.label},data ${src.selection.applied?.date||'por confirmar'}? O montante e a moeda originais são conservados;resultado anterior permanece no histórico até novo cálculo.`))apply({fxSelection:structuredClone(src.selection),fxLink:{id:src.id,fingerprint:src.fingerprint}});}}>Reutilizar selecção cambial {src.id}</button></div>)}<SimulationFxSelection row={irpcFxRow(id,input)} onChange={patch=>{if(patch.fxSelection)apply(updateIrpcFx(input,patch.fxSelection));}}/></div>;
|
||||
const ordinarySelect=(r:IrpcRow,k:keyof IrpcOrdinaryFacts,label:string,options:Array<[string,string]>)=><label className="block">{label}<select value={String((r.ordinary??emptyIrpcOrdinaryFacts())[k]??'')} onChange={e=>change(r.id,{ordinary:{...(r.ordinary??emptyIrpcOrdinaryFacts()),[k]:e.target.value}})}>{options.map(([v,l])=><option key={v} value={v}>{l}</option>)}</select></label>;
|
||||
const paymentChange=(id:string,p:Partial<IrpcPayment>)=>save({...book,payments:book.payments.map(v=>v.id===id?{...v,...p}:v)});
|
||||
return<section aria-label="Retenções de IRPC" className="space-y-4 rounded border p-4"><h4>IRPC — retenções e preparação documental</h4><p>Reutilize documentos e contrapartes existentes. Classifique o rendimento e o evento aplicável:facturar,pagar e reconhecer o custo são factos distintos. O conjunto calcula os casos digitais de 10%, as taxas normais de 20% e as taxas especiais do artigo 62(4) nos âmbitos identificados. Dispensas dos artigos 68(d/f) e limite convencional de royalties Portugal–Moçambique são calculados nos casos documentados. Informação insuficiente condiciona apenas o respectivo rendimento.</p>{error&&<p role="alert">{error}</p>}
|
||||
<button type="button" className="rounded border p-2" onClick={()=>{try{save(reuseIrpcSources(draft,book));}catch(e){setError(String(e));}}}>Reutilizar contrapartes e documentos existentes</button><button type="button" className="rounded border p-2" onClick={()=>save({...book,rows:[...book.rows,newIrpcRow(uid())]})}>Acrescentar rendimento IRPC</button>
|
||||
{book.rows.map(r=><fieldset key={r.id} className="space-y-3 rounded border p-3"><legend>{r.description||r.documentRef||'Rendimento por identificar'}</legend>{r.source&&<p>Origem:{r.source.id}. Entradas deliberadamente editadas são conservadas. {sources.find(s=>s.id===r.source!.id)?.key!==r.source.key&&<><strong>Origem alterada ou indisponível.</strong>{sources.find(s=>s.id===r.source!.id)&&<><span> Base actual:{r.base};base na origem:{sources.find(s=>s.id===r.source!.id)?.amount||'desconhecida'};documento actual:{r.documentRef};origem:{sources.find(s=>s.id===r.source!.id)?.documentRef||'desconhecido'}.</span><button type="button" className="rounded border p-2" onClick={()=>{const s=sources.find(s=>s.id===r.source!.id)!;if(window.confirm('Confirma a reconciliação da origem indicada, conservando as entradas deliberadas e os resultados anteriores no histórico?'))change(r.id,{source:{...r.source!,key:s.key}});}}>Conservar edições e reconciliar origem</button></>}</>}</p>}
|
||||
<div className="grid gap-3 md:grid-cols-2">{select(r,'direction','Perspectiva',[['paid','Rendimento pago — retenção a entregar'],['received','Rendimento recebido — retenção sofrida']])}{field(r,'counterparty','Contraparte')}{field(r,'documentRef','Documento / operação / linha distinta')}{field(r,'description','Descrição')}{select(r,'origin','Origem',[['projected','Projecção com pressupostos'],['effective','Factos documentados']])}{select(r,'kind','Natureza do rendimento',irpcKinds)}{field(r,'base','Base do rendimento, separada do IVA')}{field(r,'currency','Moeda da base')}{select(r,'recipientIrpc','Titular sujeito a IRPC neste rendimento',yn)}{select(r,'payerIrpc','Devedor sujeito a IRPC',yn)}{select(r,'residence','Residência do titular',[['','Por esclarecer'],['resident','Residente'],['nonresident','Não residente']])}{r.residence==='nonresident'&&<>{select(r,'permanentEstablishment','Existe estabelecimento estável em Moçambique',yn)}{r.permanentEstablishment==='yes'&&select(r,'attributable','Rendimento imputável ao estabelecimento',yn)}</>}{select(r,'mozambique','Rendimento obtido em Moçambique, conforme qualificação legal',yn)}{field(r,'qualificationRef','Suporte / pressuposto da qualificação, residência e incidência')}{select(r,'specialRelief','Convenção, isenção ou dispensa aplicável',[['','Por esclarecer'],['none','Não invocada neste caso qualificado'],['claimed','Invocada — identificar regra e condições']])}{r.specialRelief==='claimed'&&field(r,'reliefRef','Artigo, condições, período e prova invocados')}</div>
|
||||
{r.specialRelief==='claimed'&&<fieldset className="space-y-2 rounded border p-2"><legend>Preparação de dispensa ou convenção</legend><p>Reutiliza contraparte, residência, estabelecimento estável e documento deste rendimento.</p><label>Enquadramento<select value={r.reliefPreparation?.kind??'dispensation'} onChange={e=>change(r.id,{reliefPreparation:{...(r.reliefPreparation??newIrpcReliefPreparation()),kind:e.target.value as 'dispensation'|'exemption'|'treaty'}})}><option value="dispensation">Dispensa de retenção</option><option value="exemption">Isenção</option><option value="treaty">Convenção</option></select></label>{(['instrument','article','country','residenceDocument','validFrom','validTo','conditions','evidence'] as const).filter(k=>!['country','residenceDocument'].includes(k)||r.reliefPreparation?.kind==='treaty').map(k=><label key={k} className="block">{({instrument:'Diploma / convenção',article:'Artigo',country:'Estado contratante',residenceDocument:'Referência do documento de residência fiscal',validFrom:'Aplicável desde',validTo:'Aplicável até',conditions:'Condições do benefício',evidence:'Referências dos documentos existentes'})[k]}<input type={k==='validFrom'||k==='validTo'?'date':'text'} value={r.reliefPreparation?.[k]??''} onChange={e=>change(r.id,{reliefPreparation:{...(r.reliefPreparation??newIrpcReliefPreparation()),[k]:e.target.value}})}/></label>)}<label>Regra aplicável<select value={r.reliefPreparation?.rule??''} onChange={e=>change(r.id,{reliefPreparation:{...(r.reliefPreparation??newIrpcReliefPreparation()),rule:e.target.value as NonNullable<IrpcRow['reliefPreparation']>['rule']}})}>{irpcReliefRules.map(([v,l])=><option key={v} value={v}>{l}</option>)}</select></label>{(r.reliefPreparation?.rule==='ptMz12'?(['treatyResident','beneficialOwner','payerResidentMz','armLength','royaltyDefinition'] as const):r.reliefPreparation?.rule==='cirpc68f'?(['subjectToIrpc','notTransparent'] as const):r.reliefPreparation?.rule==='cirpc68d'?(['subjectToIrpc'] as const):[]).map(k=><label key={k} className="block">{({subjectToIrpc:'Titular sujeito a IRPC relativamente a este rendimento (ainda que isento)',notTransparent:'Sociedade fora da transparência fiscal — 6(1)c',treatyResident:'Residência em Portugal nos termos do artigo 4 documentada',beneficialOwner:'Titular é beneficiário efectivo das royalties',payerResidentMz:'Devedor residente em Moçambique; origem do rendimento conforme 12(5)',armLength:'Montante sem excesso por relações especiais — 12(6)',royaltyDefinition:'Rendimento corresponde à definição de royalties — 12(3), versão 2009'})[k]}<select value={r.reliefPreparation?.facts?.[k]??''} onChange={e=>change(r.id,{reliefPreparation:{...(r.reliefPreparation??newIrpcReliefPreparation()),facts:{...r.reliefPreparation?.facts,[k]:e.target.value as ''|'yes'|'no'}}})}>{yn.map(([v,l])=><option key={v} value={v}>{l}</option>)}</select></label>)}<p>{prepareIrpcRelief(r,r.reliefPreparation??newIrpcReliefPreparation()).missing.length?'Faltam elementos documentais; confira artigo, período e provas.':'Documentos registados; calcule para conferir as condições da regra seleccionada.'}</p></fieldset>}
|
||||
{r.currency!=='MZN'&&renderFx(r.id,r,patch=>change(r.id,patch))}
|
||||
{r.kind&&!['digital_goods','digital_services','emoney_commission','other'].includes(r.kind)&&<fieldset className="space-y-2 rounded border p-2"><legend>Factos específicos do rendimento</legend>{ordinarySelect(r,'nonDigital','Rendimento fora do âmbito digital/moeda electrónica do artigo62(5)',yn)}{r.kind==='capital'&&ordinarySelect(r,'capitalType','Categoria dos capitais',[['','Por esclarecer'],['unlisted_interest','Juros não cotados, fora das permutas de liquidez'],['unlisted_dividend','Dividendos não cotados'],['listed_debt_mz','Juros de bilhetes do Tesouro/títulos de dívida cotados em bolsa —62(3)a'],['listed_other_mz','Outros títulos cotados na Bolsa de Valores de Moçambique —62(4)d'],['liquidity_repo','Juros de permutas de liquidez em bancos,com ou sem garantia —62(3)b'],['other','Outra categoria']])}{r.kind==='capital'&&r.residence==='resident'&&['listed_debt_mz','liquidity_repo'].includes(r.ordinary?.capitalType??'')&&ordinarySelect(r,'capitalSeatAndManagement','Titular com sede e direcção efectiva nacionais —62(3)',yn)}{r.kind==='rent'&&ordinarySelect(r,'rentOwnPropertyManagement','Titular é sociedade com objecto de gestão de imóveis próprios',yn)}{(['telecom','international_transport','connected_installation','aircraft_maintenance_charter','rural_public_electrification','maritime_fishing_cabotage'].includes(r.kind)||r.kind==='capital'&&r.ordinary?.capitalType==='listed_other_mz')&&ordinarySelect(r,'specialFacts','Factos exactos da categoria62(4) identificados no suporte — não apenas actividade semelhante',yn)}<label className="block">Suporte ou pressuposto específico da classificação<input value={r.ordinary?.reference??''} onChange={e=>change(r.id,{ordinary:{...(r.ordinary??emptyIrpcOrdinaryFacts()),reference:e.target.value}})}/></label></fieldset>}
|
||||
<h5>Eventos aplicáveis — artigo67(5), versão2026</h5>{r.events.map((e,n)=><div key={n} className="grid gap-2 md:grid-cols-3"><label>Evento<select value={e.kind} onChange={ev=>change(r.id,{events:r.events.map((v,j)=>j===n?{...v,kind:ev.target.value as IrpcEvent['kind']}:v)})}>{eventLabels.map(([k,l])=><option key={k} value={k}>{l}</option>)}</select></label><label>Data<input type="date" value={e.date} onChange={ev=>change(r.id,{events:r.events.map((v,j)=>j===n?{...v,date:ev.target.value}:v)})}/></label><label>Suporte / pressuposto<input value={e.reference} onChange={ev=>change(r.id,{events:r.events.map((v,j)=>j===n?{...v,reference:ev.target.value}:v)})}/></label></div>)}<button type="button" onClick={()=>change(r.id,{events:[...r.events,{kind:'cost',date:'',reference:''}]})}>Acrescentar evento aplicável</button>{select(r,'eventsComplete','Todos os eventos aplicáveis identificados, incluindo custo quando aplicável',yn)}
|
||||
{select(r,'settlement','Reconciliação da entrega / prova de retenção sofrida',[['','Por esclarecer'],['none','Ainda sem entrega / prova'],['complete','Pagamentos / provas associados completos']])}{field(r,'settlementRef','Referência da situação declarada')}
|
||||
{r.allocations.map((a,n)=><div key={n}><label>Pagamento / prova existente<select value={a.paymentId} onChange={e=>change(r.id,{allocations:r.allocations.map((v,j)=>j===n?{...v,paymentId:e.target.value}:v)})}><option value="">Seleccione</option>{book.payments.map(p=><option key={p.id} value={p.id}>{p.reference} — {p.amount} {p.currency} — {p.kind}</option>)}</select></label><label>Parcela usada em MZN<input value={a.amount} onChange={e=>change(r.id,{allocations:r.allocations.map((v,j)=>j===n?{...v,amount:e.target.value}:v)})}/></label></div>)}{r.settlement==='complete'&&<button type="button" onClick={()=>change(r.id,{allocations:[...r.allocations,{paymentId:'',amount:''}]})}>Associar pagamento/prova existente</button>}
|
||||
</fieldset>)}
|
||||
<h5>Pagamentos e comprovativos reutilizáveis neste livro</h5><p>Uma referência é registada uma vez e repartida entre rendimentos sem exceder o montante. Pagamento ao fornecedor não é entrega à AT; pagamentos de IVA conservam o seu vínculo anterior e não são automaticamente usados em IRPC.</p>{book.payments.map(p=><fieldset key={p.id} className="grid gap-2 rounded border p-2 md:grid-cols-3"><label>Natureza<select value={p.kind} onChange={e=>paymentChange(p.id,{kind:e.target.value as IrpcPayment['kind']})}><option value="remittance">Entrega IRPC à AT</option><option value="withholding_proof">Comprovativo de retenção sofrida</option><option value="beneficiary">Pagamento ao beneficiário</option></select></label>{(['reference','date','amount','currency']as const).map(k=><label key={k}>{({reference:'Referência',date:'Data',amount:'Montante',currency:'Moeda'})[k]}<input type={k==='date'?'date':'text'} value={p[k]} onChange={e=>paymentChange(p.id,{[k]:e.target.value})}/></label>)}{p.currency!=='MZN'&&(p.kind==='withholding_proof'?renderFx(p.id,p,patch=>paymentChange(p.id,patch)):<p>A entrega IRPC à AT requer o montante MZN do comprovativo; converter o pagamento ao beneficiário não prova entrega do imposto.</p>)}</fieldset>)}<button type="button" onClick={()=>save({...book,payments:[...book.payments,{id:uid(),kind:'remittance',reference:'',date:'',amount:'',currency:'MZN'}]})}>Registar pagamento/prova para reutilização</button>
|
||||
<div className="space-x-3"><button type="button" className="rounded border p-2" onClick={()=>{try{save(recordIrpc(draft,book));}catch(e){setError(String(e));}}}>Calcular e guardar retenções IRPC</button><button type="button" className="rounded border p-2" onClick={()=>{try{const html=irpcPreparationHtml(draft),u=URL.createObjectURL(new Blob([html],{type:'text/html;charset=utf-8'})),a=document.createElement('a');a.href=u;a.download='IRPC-L5-preparacao.html';a.click();URL.revokeObjectURL(u);setError('');}catch(e){setError(String(e));}}}>Exportar preparação IRPC</button></div>
|
||||
<SimulationIrpcMrfta draft={draft} book={book} save={save}/>
|
||||
{irpcReportRows(draft).slice(0,book.result?.rows.length??0).map((r,n)=><p key={n}><strong>{r[1]}</strong>: {r[2]}<small className="block">{r[3]}</small></p>)}<p>Formalidades externas:MRFTA19/39 e guia M/39 preservados. CER/rubricas digitais e remissão67(7)→62(6) por corresponder. Exportar não submete nem obtém aceitação AT. L5 continua aberto;ISPC e IRPS conservam os seus limites anteriores.</p></section>;
|
||||
}catch(e){return<p role="alert">{String(e)}</p>;}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {ispcEligibilityBook,ispcEligibilityCurrent} from '@/lib/ispc-eligibility
|
||||
import {ispcQuarterBook,saveIspcQuarterBook,recordIspcQuarter,ispcQuarterReportRows,type IspcQuarterInput,type IspcQuarterBook} from '@/lib/ispc-quarter';
|
||||
export function SimulationIspcQuarter({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
|
||||
const[error,setError]=useState('');if(draft.fiscal_component!=='ispc')return null;
|
||||
try{if(ispcEligibilityBook(draft).input.mode==='occasional')return<p>Actividade habitual:campos trimestrais conservados no histórico. Use o percurso de operações ocasionais próprias abaixo.</p>;}catch{/* Preserve the existing explicit recovery error below. */}
|
||||
let book:IspcQuarterBook;try{book=ispcQuarterBook(draft);}catch(e){return<p role="alert">{String(e)}</p>;}
|
||||
const i=book.input,save=(b:IspcQuarterBook)=>{try{onChange('modelo_a_snapshot',saveIspcQuarterBook(draft,b));setError('');}catch(e){setError(String(e));}},change=(patch:Partial<IspcQuarterInput>)=>save({...book,input:{...i,...patch}});
|
||||
let service=false,ready=false;try{const e=ispcEligibilityBook(draft);ready=ispcEligibilityCurrent(draft,e)&&e.result?.status==='compatible';service=e.input.activities.some(a=>['services12','liberal15'].includes(a));}catch{/* Show explicit preparation issue below, never fabricate eligibility. */}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {useState} from 'react';
|
||||
import {ispcEligibilityBook} from '@/lib/ispc-eligibility';
|
||||
import {ispcWithholdingBook} from '@/lib/ispc-withholding';
|
||||
import {decimalFraction,addFractions} from '@/lib/fiscal-rational';
|
||||
import {ispcResidualBook,saveIspcResidualBook,recordIspcResidual,priorOccasionalSettlement,ispcResidualReportRows,ispcFormalPreparationHtml,type ResidualKind} from '@/lib/ispc-residuals';
|
||||
export function SimulationIspcResiduals({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
|
||||
const[error,setError]=useState('');if(draft.fiscal_component!=='ispc')return null;
|
||||
try{
|
||||
const own=ispcResidualBook(draft,'occasional'),acq=ispcResidualBook(draft,'acquisitions'),formal=ispcResidualBook(draft,'formalities'),e=ispcEligibilityBook(draft),w=ispcWithholdingBook(draft);let above=false;try{const sum=addFractions([decimalFraction(w.input.base),decimalFraction(w.input.priorAnnualPurchases)]);above=BigInt(sum.numerator)>2500000n*BigInt(sum.denominator);}catch{/* Unknown is still handled in existing acquisition panel. */}
|
||||
const book=(k:ResidualKind)=>k==='occasional'?own:k==='acquisitions'?acq:formal;
|
||||
const change=(k:ResidualKind,p:Record<string,string>)=>{const b=book(k);onChange('modelo_a_snapshot',saveIspcResidualBook(draft,{...b,input:{...b.input,...p}}));};
|
||||
const field=(k:ResidualKind,n:string,label:string,type='text')=><label key={n} className="block">{label}<input className="block rounded border p-2" type={type} value={book(k).input[n]??''} onChange={ev=>change(k,{[n]:ev.target.value})}/></label>;
|
||||
const select=(k:ResidualKind,n:string,label:string,options:Array<[string,string]>)=><label key={n} className="block">{label}<select className="block rounded border p-2" value={book(k).input[n]??''} onChange={ev=>change(k,{[n]:ev.target.value})}>{options.map(([v,l])=><option key={v} value={v}>{l}</option>)}</select></label>;
|
||||
const yesno=(k:ResidualKind,n:string,label:string)=>select(k,n,label,[['','Por esclarecer'],['yes','Sim'],['no','Não']]);
|
||||
const save=(k:ResidualKind)=>{try{onChange('modelo_a_snapshot',saveIspcResidualBook(draft,recordIspcResidual(draft,book(k))));setError('');}catch(err){setError(String(err));}};
|
||||
const activities:Array<[string,string]>=[['','Por identificar'],['forestry','Silvícola'],['fishery','Pesqueira'],['livestock','Pecuária'],['agriculture','Agrícola'],['poultry','Avícola'],['beekeeping','Apícola'],['other','Outra—fora do recorte ocasional']];
|
||||
return<section aria-label="ISPC — operações ocasionais, limite e formalidades" className="space-y-4">{error&&<p role="alert">{error}</p>}
|
||||
{e.input.mode==='occasional'&&<section className="space-y-3 rounded border p-4"><h4>Operações ocasionais próprias</h4><p>Percurso do vendedor ocasional, distinto da retenção feita pelo comprador. Uma retenção liberatória comprovada não gera segunda liquidação própria.</p>{select('occasional','personType','Tipo de pessoa destas operações',[['','Por identificar'],['singular','Pessoa singular'],['collective','Pessoa colectiva']])}{select('occasional','origin','Natureza dos dados',[['projected','Pressupostos de simulação'],['effective','Factos efectivos declarados']])}{field('occasional','currency','Moeda—MZN')}{field('occasional','operationDate','Data das operações','date')}{select('occasional','quarter','Trimestre2026',[['','Por identificar'],['1','1'],['2','2'],['3','3'],['4','4']])}{yesno('occasional','territory','Operação realizada em território nacional')}{yesno('occasional','noHabitual','Não exerce actividade habitual')}{yesno('occasional','sporadic','Acto não previsível nem reiterado')}{select('occasional','activity','Natureza da operação',activities)}{field('occasional','reference','Documentos ou pressupostos da operação')}{field('occasional','amount','Volume de todas estas operações do trimestre em MZN')}{select('occasional','settlement','Via aplicável',[['','Por identificar'],['self','Liquidação própria por opção ISPC'],['withheld','Retenção liberatória documentada pelo adquirente']])}
|
||||
{own.input.settlement==='self'&&<>{yesno('occasional','allQuarterOperations','Inclui todas as operações ocasionais do sujeito passivo no trimestre')}{field('occasional','priorYearTurnover','Volume de referência2025 para acesso, incluindo zero expresso')}{field('occasional','priorYearRef','Suporte da referência2025')}{yesno('occasional','organizedRequired','Obrigado a contabilidade organizada para impostos sobre rendimento')}{select('occasional','option','Opção para estas operações',[['preparation','Em preparação—apenas cenário'],['opted','Opção documentada'],['not_opted','Não optou']])}{own.input.option==='opted'&&<>{field('occasional','optionRef','Referência da opção')}{field('occasional','optionDate','Data da opção','date')}</>}{field('occasional','annual2026','Volume anual2026 para escalão')}{select('occasional','annualOrigin','Origem do volume anual2026',[['','Por identificar'],['forecast','Previsão—cenário'],['closed_year','Volume anual encerrado documentado']])}{field('occasional','annualRef','Suporte ou pressuposto do volume anual2026')}{field('occasional','priorInYear','Volume de operações anteriores no ano2026')}{priorOccasionalSettlement(own)&&<><p>Existe retenção liberatória guardada para esta operação. A mudança de via não a apaga nem gera uma segunda prestação.</p>{yesno('occasional','withholdingCorrected','Retenção anterior corrigida/anulada segundo documento externo')}{field('occasional','withholdingCorrectionRef','Referência externa da correcção da retenção anterior')}</>}</>}
|
||||
{own.input.settlement==='withheld'&&<>{yesno('occasional','buyerIspc','Adquirente sujeito passivo ISPC')}{yesno('occasional','unregistered','Vendedor não inscrito para tributação')}{field('occasional','buyerAnnualPurchases','Aquisições globais anuais do adquirente, incluindo estas operações')}{field('occasional','withheldActual','Montante efectivamente retido em MZN')}{field('occasional','withheldRef','Documento da retenção liberatória')}</>}
|
||||
<button type="button" className="rounded border p-2" onClick={()=>save('occasional')}>Calcular e guardar operações ocasionais</button></section>}
|
||||
{(above||acq.result)&&<section className="space-y-3 rounded border p-4"><h4>Aquisições acima do limite global</h4><p>Reutiliza a aquisição, os seus documentos e o acumulado global guardados acima. A parcela excedente não recebe automaticamente a taxa de 5% nem a taxa de 20% do excesso de vendas ISPC.</p>{select('acquisitions','route','Tratamento próprio da parcela excedente',[['unknown','Por identificar—mostrar parcelas sem total presumido'],['ispc_supplier','Fornecedor com opção ISPC aplicável'],['irps_isolated','Acto isolado sujeito a IRPS57(2)k']])}{acq.input.route==='ispc_supplier'&&field('acquisitions','supplierOptionRef','Referência da opção/registo do fornecedor e operações abrangidas')}{acq.input.route==='irps_isolated'&&<>{yesno('acquisitions','noIspcOnExcess','Sem opção ISPC aplicável a esta parcela, segundo documento indicado')}{yesno('acquisitions','isolatedAct','Acto isolado qualificado pelo artigo8(3h,4)doIRPS')}{yesno('acquisitions','incomeInMoz','Rendimento obtido em Moçambique')}{field('acquisitions','grossExcess','Rendimento ilíquido da parcela excedente em MZN—sem dedução de custos ou IVA')}{field('acquisitions','grossRef','Suporte da base ilíquida')}{field('acquisitions','reference','Suporte da classificação IRPS desta parcela')}</>}<button type="button" className="rounded border p-2" onClick={()=>save('acquisitions')}>Calcular e guardar parcela excedente</button></section>}
|
||||
<section className="space-y-3 rounded border p-4"><h4>Formalidades de2026—preparação do B–Geral</h4><p>O Comunicado AT9/2026indica o B–Geral durante a adaptação do sistema, com pagamentos em Janeiro, Abril, Julho e Outubro. O Modelo30disponível conserva campos antigos; não os aplicamos a2026. Esta preparação não submete nem paga à AT.</p>{select('formalities','sourceKind','Cálculo a preparar',[['quarter','Trimestral habitual'],['occasional','Operações ocasionais próprias'],['acquisitions','Retenção da parcela excedente qualificada']])}{formal.input.sourceKind==='acquisitions'&&field('formalities','retentionFormRef','Modelo/código e instrução para entrega das retenções ISPC/IRPS, separados do imposto próprio')}{field('formalities','name','Nome/designação social—quadro5')}{field('formalities','nuit','NUIT—quadro3')}{field('formalities','fiscalArea','Área Fiscal/UGC—quadro3')}{field('formalities','address','Domicílio fiscal—quadro6')}{select('formalities','declaration','Tipo de declaração—quadro1',[['initial','Inicial'],['substitution','Substituição']])}{field('formalities','formMonth','Mês a inscrever no quadro2—não presumido pelo mês de pagamento')}{field('formalities','formYear','Ano a inscrever no quadro2')}{field('formalities','formPeriodRef','Instrução/correspondência usada para o período do quadro2')}{select('formalities','interestSituation','Juros compensatórios—quadro10',[['','Por esclarecer'],['none','Sem juros, segundo suporte indicado'],['amount','Montante documentado']])}{formal.input.interestSituation==='amount'&&field('formalities','interestAmount','Montante de juros documentado')}{field('formalities','interestRef','Suporte da situação dos juros')}{select('formalities','externalStatus','Histórico externo declarado',[['preparation','Em preparação'],['submitted','Submetido'],['received','Recebido pela AT'],['paid','Pagamento efectuado']])}{formal.input.externalStatus!=='preparation'&&<>{field('formalities','externalRef','Referência externa')}{field('formalities','externalDate','Data do acto externo','date')}</>}
|
||||
<button type="button" className="rounded border p-2" onClick={()=>save('formalities')}>Preparar e guardar campos B–Geral</button><button type="button" className="rounded border p-2" onClick={()=>{try{const html=ispcFormalPreparationHtml(draft),url=URL.createObjectURL(new Blob([html],{type:'text/html;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download='ISPC-preparacao-B-Geral.html';a.click();URL.revokeObjectURL(url);setError('');}catch(err){setError(String(err));}}}>Descarregar preparação B–Geral</button></section>
|
||||
{ispcResidualReportRows(draft).map((r,n)=><p key={n}><strong>{r[1]}:</strong> {r[2]}<small className="block">{r[3]}</small></p>)}
|
||||
</section>;
|
||||
}catch(err){return<p role="alert">{String(err)}</p>;}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import {ispcEligibilityBook} from '@/lib/ispc-eligibility';
|
||||
import {ispcWithholdingBook,saveIspcWithholdingBook,recordIspcWithholding,ispcWithholdingReportRows,type IspcWithholdingInput} from '@/lib/ispc-withholding';
|
||||
export function SimulationIspcWithholding({draft,onChange}:{draft:Record<string,unknown>;onChange:(field:string,value:string)=>void}){
|
||||
if(draft.fiscal_component!=='ispc')return null;
|
||||
try{if(ispcEligibilityBook(draft).input.mode==='occasional')return<p>Retenções do adquirente:entradas preservadas. Neste percurso de vendedor ocasional,registe a retenção recebida no quadro próprio abaixo.</p>;}catch{/* Existing recovery error stays explicit. */}
|
||||
try{const b=ispcWithholdingBook(draft),i=b.input,change=(p:Partial<IspcWithholdingInput>)=>onChange('modelo_a_snapshot',saveIspcWithholdingBook(draft,{...b,input:{...i,...p}}));return<section aria-label="Retenções ISPC" className="space-y-3 rounded border p-4"><h4>ISPC — retenção em aquisições ocasionais</h4><label className="block">Existem aquisições abrangidas pelo artigo 12(3–4)?<select value={i.applicable} onChange={e=>change({applicable:e.target.value as IspcWithholdingInput['applicable']})}><option value="">Por esclarecer</option><option value="no">Não</option><option value="yes">Sim — conferir condições</option></select></label>{i.applicable==='yes'&&<><p>Registe apenas aquisições do mesmo trimestre. O acumulado anterior cobre todas as aquisições desta via no ano e evita reutilizar o limite por fornecedor ou oportunidade.</p>{(['quarter','base','priorAnnualPurchases','reference'] as const).map(k=><label className="block" key={k}>{{quarter:'Trimestre de 2026',base:'Base de aquisições deste trimestre em MZN',priorAnnualPurchases:'Aquisições globais anteriores de 2026 em MZN',reference:'Suporte das aquisições e condições'}[k]}<input className="block rounded border p-2" value={i[k]} onChange={e=>change({[k]:e.target.value})}/></label>)}{(['naturalOccasional','unregistered','related']as const).map(k=><label className="block" key={k}>{{naturalOccasional:'Fornecedor pessoa singular ocasional abrangido por 3(3–4)',unregistered:'Fornecedor não inscrito para tributação',related:'Bens relacionados com a actividade do adquirente'}[k]}<select value={i[k]} onChange={e=>change({[k]:e.target.value})}><option value="">Por esclarecer</option><option value="yes">Sim</option><option value="no">Não</option></select></label>)}<label className="block">Actividade da operação ocasional<select value={i.activity} onChange={e=>change({activity:e.target.value as IspcWithholdingInput['activity']})}>{[['','Por identificar'],['forestry','Silvícola'],['fishery','Pesqueira'],['livestock','Pecuária'],['agriculture','Agrícola'],['poultry','Avícola'],['beekeeping','Apícola'],['other','Outra']].map(([v,l])=><option key={v} value={v}>{l}</option>)}</select></label></>}<button type="button" className="rounded border p-2" onClick={()=>onChange('modelo_a_snapshot',saveIspcWithholdingBook(draft,recordIspcWithholding(draft,b)))}>Conferir e guardar retenção ISPC</button>{ispcWithholdingReportRows(draft).map((r,n)=><p key={n}><strong>{r[1]}:</strong> {r[2]}<small className="block">{r[3]}</small></p>)}</section>;}catch(e){return<p role="alert">{String(e)}</p>;}
|
||||
}
|
||||
|
||||
@@ -58,10 +58,28 @@ const menuTranslations = [
|
||||
'solicitudes recibidas',
|
||||
],
|
||||
[
|
||||
'Porquê investir em Moçambique',
|
||||
'Why invest in Mozambique',
|
||||
'Pourquoi investir au Mozambique',
|
||||
'Por qué invertir en Mozambique',
|
||||
'Porquê investir em Moçambique?',
|
||||
'Why invest in Mozambique?',
|
||||
'Pourquoi investir au Mozambique ?',
|
||||
'Por qué invertir en Mozambique?',
|
||||
],
|
||||
[
|
||||
'Partir de uma ideia',
|
||||
'Start with an idea',
|
||||
'Partir d’une idée',
|
||||
'Partir de una idea',
|
||||
],
|
||||
[
|
||||
'Consultar oportunidades',
|
||||
'Browse opportunities',
|
||||
'Consulter les opportunités',
|
||||
'Consultar oportunidades',
|
||||
],
|
||||
[
|
||||
'Simulador Fiscal',
|
||||
'Tax simulator',
|
||||
'Simulateur fiscal',
|
||||
'Simulador fiscal',
|
||||
],
|
||||
[
|
||||
'Oportunidades de investimento',
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
type Choice = { id: string; name: string; source_code?: string | null };
|
||||
|
||||
/** One search field owns the list and commits only an explicitly chosen option. */
|
||||
export default function TerritorySearchSelect<T extends Choice>({
|
||||
id, label, search, onSearch, options, selected, onChange, disabled = false,
|
||||
loading = false, error, required = false, className, placeholder, children,
|
||||
}: {
|
||||
id: string; label: string; search: string; onSearch: (query: string) => void;
|
||||
options: T[]; selected?: T; onChange: (option?: T) => void;
|
||||
disabled?: boolean; loading?: boolean; error?: string; required?: boolean;
|
||||
className?: string; placeholder?: string; children?: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(-1);
|
||||
const activeRef = useRef<HTMLButtonElement | null>(null);
|
||||
const expanded = open && !disabled;
|
||||
const listId = `${id}-options`;
|
||||
useEffect(() => { if (expanded) activeRef.current?.scrollIntoView({ block: 'nearest' }); }, [active, expanded]);
|
||||
const close = () => { setOpen(false); setActive(-1); onSearch(''); };
|
||||
const choose = (option?: T) => { onChange(option); close(); };
|
||||
return <div className="relative" onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) close();
|
||||
}}>
|
||||
<input id={id} className={className} role="combobox" aria-label={`Pesquisar ${label}`}
|
||||
aria-autocomplete="list" aria-expanded={expanded} aria-controls={expanded ? listId : undefined}
|
||||
aria-activedescendant={expanded && !loading && options[active] ? `${listId}-${active}` : undefined}
|
||||
aria-invalid={Boolean(error)} aria-describedby={error ? `${id}-error` : undefined}
|
||||
aria-required={required || undefined} required={required} autoComplete="off" maxLength={100}
|
||||
disabled={disabled} value={expanded ? search : selected?.name ?? ''}
|
||||
placeholder={disabled ? 'Seleccione o nível anterior' : placeholder ?? `Pesquisar ${label.toLowerCase()}`}
|
||||
onFocus={() => { setOpen(true); setActive(-1); onSearch(''); }}
|
||||
onClick={() => setOpen(true)}
|
||||
onChange={(event) => { setOpen(true); setActive(-1); onSearch(event.target.value); }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); close(); }
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault(); setOpen(true);
|
||||
setActive((current) => !options.length ? -1 : event.key === 'ArrowDown'
|
||||
? Math.min(current + 1, options.length - 1) : current < 0 ? options.length - 1 : Math.max(0, current - 1));
|
||||
}
|
||||
if (event.key === 'Enter' && expanded) {
|
||||
event.preventDefault();
|
||||
if (!loading && options[active < 0 ? 0 : active]) choose(options[active < 0 ? 0 : active]);
|
||||
}
|
||||
}} />
|
||||
{selected && !disabled && <button type="button" className="mt-1 text-sm underline"
|
||||
aria-label={`Limpar ${label}`} onClick={() => choose()}>Limpar</button>}
|
||||
{error && <p id={`${id}-error`} role="alert">{error}</p>}
|
||||
{expanded && <div className="absolute z-50 mt-1 max-h-72 w-full overflow-y-auto rounded border bg-background p-1 text-foreground shadow-lg">
|
||||
<div id={listId} role="listbox" aria-label={label} aria-busy={loading}>
|
||||
{loading && <p role="status" className="p-2">A carregar…</p>}
|
||||
{!loading && options.length === 0 && <p className="p-2">{error ? 'Não foi possível carregar as opções.' : 'Sem resultados para esta pesquisa.'}</p>}
|
||||
{options.map((option, index) => <button key={option.id} id={`${listId}-${index}`} type="button"
|
||||
ref={index === active ? activeRef : undefined} role="option" tabIndex={-1}
|
||||
aria-selected={option.id === selected?.id} disabled={loading}
|
||||
className={`block w-full rounded p-2 text-left hover:bg-accent ${index === active ? 'bg-accent' : ''}`}
|
||||
onMouseDown={(event) => event.preventDefault()} onClick={() => choose(option)}>
|
||||
{option.name}{option.source_code ? ` (${option.source_code})` : ''}
|
||||
</button>)}
|
||||
</div>
|
||||
{children}
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
Reference in New Issue
Block a user