Files
Investidor-V2/resources/js/components/simulation-business-plan.tsx
T

3153 lines
163 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import {
compareBusinessScenarios,
exportBusinessPlanCsv,
forecastBusinessPlan,
MAX_BUSINESS_PLAN_SNAPSHOT_LENGTH,
restoreBusinessPlanSnapshot,
serializeBusinessPlanSnapshot,
type BusinessFinancialPlanInput,
type InventoryPurchasePeriod,
type SalesReceivableRecovery,
type ScenarioAdjustment,
} from '@/lib/business-financial-plan';
import {
isSimulationCurrency,
simulationCurrencyOptions,
} from '@/lib/simulation-contract';
import { buildFinancialFiscalBridge } from '@/lib/financial-fiscal-bridge';
const emptyPlan: BusinessFinancialPlanInput = {
currency: '',
presentationCurrency: '',
fxRate: '',
fxDate: '',
fxSource: '',
openingCash: '0',
products: [
{
label: 'Produto ou serviço',
monthlyUnits: '100',
unitPrice: '1000',
unitVariableCost: '400',
},
],
monthlyCollections: '100000',
salesCollectionLagMonths: null,
salesCollectionPercent: '100',
salesRecoverySchedule: [],
openingReceivablesCollectionMonth: null,
openingReceivablesCollectionPercent: '100',
openingReceivablesInstallments: [],
openingPayablesInstallments: [],
monthlyVariableCostPayments: '40000',
monthlyVariableCostPaymentsByPeriod: null,
monthlyInventoryPurchases: '40000',
inventoryPurchaseLeadMonths: 0,
inventoryPurchaseSchedule: null,
useInventoryPurchaseSchedule: false,
useProductInventorySchedule: false,
useSupplierPaymentTerms: false,
monthlyOperatingExpenses: '20000',
monthlyOperatingPayments: '20000',
monthlyPayrollExpense: '15000',
monthlyPayrollPayments: '15000',
assetInvestment: '100000',
inventory: '20000',
receivables: '10000',
payables: '5000',
equity: '80000',
shareholderLoans: '0',
bankLoanProceeds: '50000',
grants: '0',
assetDepreciation: '100000',
depreciationLifeMonths: 60,
monthlyInterestExpense: '0',
monthlyInterestPayment: '0',
monthlyPrincipalRepayment: '0',
periods: 12,
};
function MoneyInput({
label,
value,
onChange,
allowNegative = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
allowNegative?: boolean;
}) {
return (
<label className="block text-sm font-medium">
{label}
<input
type="number"
min={allowNegative ? undefined : '0'}
step="0.01"
value={value}
onChange={(event) => onChange(event.target.value)}
className="mt-1 w-full rounded border p-2"
/>
</label>
);
}
export function SimulationBusinessPlan({
currency: projectCurrency,
snapshot,
onCurrencyChange,
onSnapshotChange,
}: {
currency: string;
snapshot?: string;
onCurrencyChange: (value: string) => void;
onSnapshotChange: (snapshot: string) => void;
}) {
const hasOversizedStoredSnapshot =
typeof snapshot === 'string' &&
snapshot.length > MAX_BUSINESS_PLAN_SNAPSHOT_LENGTH;
const [snapshotError, setSnapshotError] = useState(
hasOversizedStoredSnapshot
? 'O snapshot guardado excede 60 KB e nao pode ser carregado com seguranca. O formulario nao o vai substituir.'
: '',
);
const [workingPlan, setWorkingPlan] =
useState<BusinessFinancialPlanInput | null>(null);
useEffect(() => setWorkingPlan(null), [snapshot]);
if (hasOversizedStoredSnapshot) {
return (
<details className="rounded-lg border border-slate-300 p-4" open>
<summary className="cursor-pointer text-lg font-semibold">
Plano financeiro empresarial
</summary>
<p
role="alert"
className="my-3 rounded border border-red-300 p-3 text-sm text-red-900"
>
{snapshotError}
</p>
</details>
);
}
const savePlan = (nextPlan: BusinessFinancialPlanInput) => {
if (hasOversizedStoredSnapshot) return;
setWorkingPlan(nextPlan);
try {
onSnapshotChange(serializeBusinessPlanSnapshot(nextPlan));
setSnapshotError('');
} catch (cause) {
setSnapshotError(
cause instanceof Error
? cause.message
: 'Nao foi possivel guardar o plano financeiro.',
);
}
};
const importPlanFile = async (file: File | undefined) => {
if (!file) return;
if (file.size > MAX_BUSINESS_PLAN_SNAPSHOT_LENGTH) {
setSnapshotError(
'O ficheiro excede 60 KB. Seleccione um plano JSON exportado pelo InvestMoz dentro deste limite.',
);
return;
}
try {
const source = await file.text();
const imported = restoreBusinessPlanSnapshot(source);
if (!imported)
throw new Error(
'O ficheiro não contém um plano JSON reconhecido pelo InvestMoz.',
);
savePlan(imported);
setSnapshotError('');
} catch (cause) {
setSnapshotError(
cause instanceof Error
? cause.message
: 'Não foi possível importar este plano.',
);
}
};
const exportPlanJson = () => {
try {
const snapshotJson = serializeBusinessPlanSnapshot({
...plan,
currency,
});
const url = URL.createObjectURL(
new Blob([snapshotJson], {
type: 'application/json;charset=utf-8',
}),
);
const link = document.createElement('a');
link.href = url;
link.download = 'plano-financeiro-investmoz.json';
link.rel = 'noopener';
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
setSnapshotError('');
} catch (cause) {
setSnapshotError(
cause instanceof Error
? cause.message
: 'Não foi possível exportar este plano.',
);
}
};
type ProductScheduleKey =
| 'monthlyUnitsByPeriod'
| 'unitPriceByPeriod'
| 'unitVariableCostByPeriod';
type MoneyScheduleKey =
| 'monthlyVariableCostPaymentsByPeriod'
| 'monthlyOperatingExpensesByPeriod'
| 'monthlyOperatingPaymentsByPeriod'
| 'monthlyPayrollExpenseByPeriod'
| 'monthlyPayrollPaymentsByPeriod';
const moneyScheduleFields: {
key: MoneyScheduleKey;
baseKey:
| 'monthlyVariableCostPayments'
| 'monthlyOperatingExpenses'
| 'monthlyOperatingPayments'
| 'monthlyPayrollExpense'
| 'monthlyPayrollPayments';
label: string;
}[] = [
{
key: 'monthlyVariableCostPaymentsByPeriod',
baseKey: 'monthlyVariableCostPayments',
label: 'Pagamento de compras/variáveis',
},
{
key: 'monthlyOperatingExpensesByPeriod',
baseKey: 'monthlyOperatingExpenses',
label: 'Despesas operacionais reconhecidas',
},
{
key: 'monthlyOperatingPaymentsByPeriod',
baseKey: 'monthlyOperatingPayments',
label: 'Pagamento de despesas operacionais',
},
{
key: 'monthlyPayrollExpenseByPeriod',
baseKey: 'monthlyPayrollExpense',
label: 'Custo de pessoal reconhecido',
},
{
key: 'monthlyPayrollPaymentsByPeriod',
baseKey: 'monthlyPayrollPayments',
label: 'Pagamento de pessoal',
},
];
const productScheduleBaseKey: Record<
ProductScheduleKey,
'monthlyUnits' | 'unitPrice' | 'unitVariableCost'
> = {
monthlyUnitsByPeriod: 'monthlyUnits',
unitPriceByPeriod: 'unitPrice',
unitVariableCostByPeriod: 'unitVariableCost',
};
const plan = workingPlan ??
restoreBusinessPlanSnapshot(snapshot) ?? {
...emptyPlan,
currency: projectCurrency,
};
// A project edit is not a conversion or permission to relabel stored sales.
const currency = plan.currency || projectCurrency;
const update = (
key: keyof BusinessFinancialPlanInput,
value:
| string
| string[]
| number
| null
| undefined
| BusinessFinancialPlanInput['openingReceivablesInstallments']
| BusinessFinancialPlanInput['salesRecoverySchedule']
| BusinessFinancialPlanInput['inventoryPurchaseSchedule']
| BusinessFinancialPlanInput['monthlyVariableCostPaymentsByPeriod'],
) =>
savePlan({
...plan,
currency,
[key]: value,
} as BusinessFinancialPlanInput);
const updateSalesRecovery = (
index: number,
key: 'month' | 'percent',
value: string | number,
) =>
update(
'salesRecoverySchedule',
(plan.salesRecoverySchedule ?? []).map((entry, row) =>
row === index
? {
...entry,
[key]:
key === 'month' ? Number(value) : String(value),
}
: entry,
) as SalesReceivableRecovery[],
);
const updateReceivableInstallment = (
index: number,
key: 'month' | 'percent',
value: string | number,
) =>
savePlan({
...plan,
currency,
openingReceivablesInstallments: (
plan.openingReceivablesInstallments ?? []
).map((installment, row) =>
row === index
? key === 'month'
? { ...installment, month: Number(value) }
: { ...installment, percent: String(value) }
: installment,
),
});
const addReceivableInstallment = () =>
update('openingReceivablesInstallments', [
...(plan.openingReceivablesInstallments ?? []),
{ month: 1, percent: '0' },
]);
const removeReceivableInstallment = (index: number) =>
update(
'openingReceivablesInstallments',
(plan.openingReceivablesInstallments ?? []).filter(
(_, row) => row !== index,
),
);
const updatePayableInstallment = (
index: number,
key: 'month' | 'percent',
value: string | number,
) =>
savePlan({
...plan,
currency,
openingPayablesInstallments: (
plan.openingPayablesInstallments ?? []
).map((installment, row) =>
row === index
? key === 'month'
? { ...installment, month: Number(value) }
: { ...installment, percent: String(value) }
: installment,
),
});
const addPayableInstallment = () =>
update('openingPayablesInstallments', [
...(plan.openingPayablesInstallments ?? []),
{ month: 1, percent: '0' },
]);
const removePayableInstallment = (index: number) =>
update(
'openingPayablesInstallments',
(plan.openingPayablesInstallments ?? []).filter(
(_, row) => row !== index,
),
);
const validHorizon =
Number.isSafeInteger(plan.periods) &&
(plan.periods ?? 0) >= 1 &&
(plan.periods ?? 0) <= 60
? (plan.periods ?? 12)
: 12;
const updatePeriods = (value: number) => {
const nextPeriods = Number.isSafeInteger(value)
? Math.max(1, Math.min(60, value))
: validHorizon;
const resizeSchedule = (
schedule: string[] | null | undefined,
baseValue: string,
) =>
schedule
? Array.from(
{
length: Math.max(nextPeriods, schedule.length),
},
(_, month) => schedule[month] ?? baseValue,
)
: undefined;
savePlan({
...plan,
currency,
periods: nextPeriods,
...Object.fromEntries(
moneyScheduleFields.map(({ key, baseKey }) => [
key,
resizeSchedule(plan[key], plan[baseKey]),
]),
),
products: plan.products.map((product) => ({
...product,
monthlyUnitsByPeriod: resizeSchedule(
product.monthlyUnitsByPeriod,
product.monthlyUnits,
),
unitPriceByPeriod: resizeSchedule(
product.unitPriceByPeriod,
product.unitPrice,
),
unitVariableCostByPeriod: resizeSchedule(
product.unitVariableCostByPeriod,
product.unitVariableCost,
),
productInventoryPurchasesByPeriod:
product.productInventoryPurchasesByPeriod
? Array.from(
{
length: Math.max(
nextPeriods,
product.productInventoryPurchasesByPeriod
.length,
),
},
(_, month) =>
product.productInventoryPurchasesByPeriod?.[
month
] ?? {
amount: '0',
leadMonths: 0,
...(plan.useSupplierPaymentTerms
? {
paymentLagMonths: 0,
paymentInstallments: [
{
lagMonths: 0,
percent: '100',
},
],
}
: {}),
},
)
: null,
})),
inventoryPurchaseSchedule: plan.inventoryPurchaseSchedule
? Array.from(
{
length: Math.max(
nextPeriods,
plan.inventoryPurchaseSchedule.length,
),
},
(_, month) =>
plan.inventoryPurchaseSchedule?.[month] ?? {
amount: plan.monthlyInventoryPurchases ?? '',
leadMonths: plan.inventoryPurchaseLeadMonths ?? 0,
},
)
: null,
});
};
const toggleInventoryPurchaseSchedule = () => {
const enabled = !(plan.useInventoryPurchaseSchedule ?? false);
const schedule =
plan.inventoryPurchaseSchedule ??
Array.from({ length: validHorizon }, () => ({
amount: plan.monthlyInventoryPurchases ?? '',
leadMonths: plan.inventoryPurchaseLeadMonths ?? 0,
}));
savePlan({
...plan,
currency,
inventoryPurchaseSchedule: schedule,
useInventoryPurchaseSchedule: enabled,
});
};
const toggleProductInventorySchedule = () => {
const enabled = !(plan.useProductInventorySchedule ?? false);
savePlan({
...plan,
currency,
useProductInventorySchedule: enabled,
useInventoryPurchaseSchedule: enabled
? false
: plan.useInventoryPurchaseSchedule,
useSupplierPaymentTerms: enabled
? plan.useSupplierPaymentTerms
: false,
products: plan.products.map((product) => ({
...product,
productInventoryOpening: product.productInventoryOpening ?? '',
productInventoryPurchasesByPeriod:
product.productInventoryPurchasesByPeriod ??
Array.from({ length: validHorizon }, () => ({
amount: '0',
leadMonths: 0,
})),
})),
});
};
const toggleSupplierPaymentTerms = () => {
const enabled = !(plan.useSupplierPaymentTerms ?? false);
savePlan({
...plan,
currency,
useInventoryPurchaseSchedule: false,
useProductInventorySchedule: true,
useSupplierPaymentTerms: enabled,
products: plan.products.map((product) => ({
...product,
productInventoryOpening: product.productInventoryOpening ?? '',
productInventoryPurchasesByPeriod: Array.from(
{
length: Math.max(
validHorizon,
product.productInventoryPurchasesByPeriod?.length ??
0,
),
},
(_, month) => ({
amount:
product.productInventoryPurchasesByPeriod?.[month]
?.amount ?? '0',
leadMonths:
product.productInventoryPurchasesByPeriod?.[month]
?.leadMonths ?? 0,
paymentLagMonths:
product.productInventoryPurchasesByPeriod?.[month]
?.paymentLagMonths ?? 0,
paymentInstallments: product
.productInventoryPurchasesByPeriod?.[month]
?.paymentInstallments ?? [
{
lagMonths:
product.productInventoryPurchasesByPeriod?.[
month
]?.paymentLagMonths ?? 0,
percent: '100',
},
],
}),
),
})),
});
};
const updateProductInventoryPurchase = (
index: number,
month: number,
key: 'amount' | 'leadMonths' | 'paymentLagMonths',
value: string | number,
) =>
savePlan({
...plan,
currency,
products: plan.products.map((product, row) =>
row === index && product.productInventoryPurchasesByPeriod
? {
...product,
productInventoryPurchasesByPeriod:
product.productInventoryPurchasesByPeriod.map(
(entry, period) =>
period === month
? {
...entry,
[key]:
key === 'amount'
? String(value)
: Number(value),
}
: entry,
),
}
: product,
),
});
const updateProductPaymentInstallment = (
index: number,
month: number,
installmentIndex: number,
key: 'lagMonths' | 'percent',
value: string | number,
) =>
savePlan({
...plan,
currency,
products: plan.products.map((product, row) =>
row === index && product.productInventoryPurchasesByPeriod
? {
...product,
productInventoryPurchasesByPeriod:
product.productInventoryPurchasesByPeriod.map(
(entry, period) =>
period === month
? {
...entry,
paymentInstallments: (
entry.paymentInstallments ?? [
{
lagMonths:
entry.paymentLagMonths ??
0,
percent: '100',
},
]
).map((part, partIndex) =>
partIndex ===
installmentIndex
? {
...part,
[key]:
key ===
'percent'
? String(
value,
)
: Number(
value,
),
}
: part,
),
}
: entry,
),
}
: product,
),
});
const addProductPaymentInstallment = (index: number, month: number) =>
savePlan({
...plan,
currency,
products: plan.products.map((product, row) =>
row === index && product.productInventoryPurchasesByPeriod
? {
...product,
productInventoryPurchasesByPeriod:
product.productInventoryPurchasesByPeriod.map(
(entry, period) =>
period === month
? {
...entry,
paymentInstallments: [
...(entry.paymentInstallments ?? [
{
lagMonths:
entry.paymentLagMonths ??
0,
percent: '100',
},
]),
{
lagMonths: 0,
percent: '0',
},
],
}
: entry,
),
}
: product,
),
});
const removeProductPaymentInstallment = (
index: number,
month: number,
installmentIndex: number,
) =>
savePlan({
...plan,
currency,
products: plan.products.map((product, row) =>
row === index && product.productInventoryPurchasesByPeriod
? {
...product,
productInventoryPurchasesByPeriod:
product.productInventoryPurchasesByPeriod.map(
(entry, period) =>
period === month
? {
...entry,
paymentInstallments: (
entry.paymentInstallments ??
[]
).filter(
(_, partIndex) =>
partIndex !==
installmentIndex,
),
}
: entry,
),
}
: product,
),
});
const updateInventoryPurchase = (
index: number,
key: keyof InventoryPurchasePeriod,
value: string | number,
) =>
savePlan({
...plan,
currency,
inventoryPurchaseSchedule: (
plan.inventoryPurchaseSchedule ?? []
).map((entry, month) =>
month === index
? {
...entry,
[key]:
key === 'amount' ? String(value) : Number(value),
}
: entry,
),
});
const toggleProductSchedule = (
index: number,
scheduleKey: ProductScheduleKey,
) =>
savePlan({
...plan,
currency,
products: plan.products.map((product, row) => {
if (row !== index) return product;
const next = { ...product };
if (next[scheduleKey]) {
delete next[scheduleKey];
} else {
const baseKey = productScheduleBaseKey[scheduleKey];
next[scheduleKey] = Array.from(
{ length: validHorizon },
() => product[baseKey],
);
}
return next;
}),
});
const updateProductSchedule = (
index: number,
scheduleKey: ProductScheduleKey,
month: number,
value: string,
) =>
savePlan({
...plan,
currency,
products: plan.products.map((product, row) =>
row === index && product[scheduleKey]
? {
...product,
[scheduleKey]: product[scheduleKey]!.map(
(units, currentMonth) =>
currentMonth === month ? value : units,
),
}
: product,
),
});
const toggleMoneySchedule = (
key: MoneyScheduleKey,
baseKey:
| 'monthlyVariableCostPayments'
| 'monthlyOperatingExpenses'
| 'monthlyOperatingPayments'
| 'monthlyPayrollExpense'
| 'monthlyPayrollPayments',
) => {
const schedule = plan[key];
const baseValue = plan[baseKey];
if (!schedule && typeof baseValue !== 'string') return;
update(
key,
schedule
? undefined
: Array.from(
{ length: validHorizon },
() => baseValue as string,
),
);
};
const updateMoneySchedule = (
key: MoneyScheduleKey,
month: number,
value: string,
) => {
const schedule = plan[key];
if (!schedule) return;
update(
key,
schedule.map((amount, currentMonth) =>
currentMonth === month ? value : amount,
),
);
};
const updateProduct = (
index: number,
key: 'label' | 'monthlyUnits' | 'unitPrice' | 'unitVariableCost',
value: string,
) =>
savePlan({
...plan,
currency,
products: plan.products.map((row, i) =>
i === index ? { ...row, [key]: value } : row,
),
});
let result;
let error = '';
try {
result = forecastBusinessPlan({ ...plan, currency });
} catch (cause) {
error =
cause instanceof Error
? cause.message
: 'Reveja os pressupostos introduzidos.';
}
const blankAdjustment: ScenarioAdjustment = {
unitVolumePercent: '',
unitPricePercent: '',
unitCostPercent: '',
operatingCostsPercent: '',
cashCollectionsPercent: '',
};
const scenarioAdjustments = plan.scenarioAdjustments ?? {
favorable: blankAdjustment,
unfavorable: blankAdjustment,
};
const updateScenario = (
scenario: 'favorable' | 'unfavorable',
key: keyof ScenarioAdjustment,
value: string,
) => {
savePlan({
...plan,
currency,
scenarioAdjustments: {
...scenarioAdjustments,
[scenario]: {
...scenarioAdjustments[scenario],
[key]: value,
},
},
});
};
const scenarioValues = [
...Object.values(scenarioAdjustments.favorable),
...Object.values(scenarioAdjustments.unfavorable),
];
const scenarioHasChanges = scenarioValues.some((value) => value !== '');
const scenarioIncomplete =
scenarioHasChanges && !scenarioValues.every((value) => value !== '');
let scenarios;
let scenarioError = '';
if (scenarioValues.every((value) => value !== '')) {
try {
scenarios = compareBusinessScenarios({
...plan,
currency,
scenarioAdjustments,
});
} catch (cause) {
scenarioError =
cause instanceof Error
? cause.message
: 'Verifique as variações dos cenários.';
}
}
const fiscalBridge = result ? buildFinancialFiscalBridge(result) : null;
return (
<details className="rounded-lg border border-slate-300 p-4" open>
<summary className="cursor-pointer text-lg font-semibold">
Plano financeiro empresarial (pressupostos editáveis)
</summary>
{plan.currency && projectCurrency && plan.currency !== projectCurrency && <p role="alert">A projecção guardada está em {plan.currency}; o projecto está em {projectCurrency}. Os valores e a moeda de origem foram preservados. Reveja o enquadramento monetário antes de os reutilizar; não foi feita conversão.</p>}
{snapshotError && (
<p
role="alert"
className="my-3 rounded border border-red-300 p-3 text-sm text-red-900"
>
{snapshotError}
</p>
)}
<p className="my-3 text-sm text-slate-700">
Projecção indicativa com valores de exemplo editáveis ou
pressupostos introduzidos pela pessoa. Facturação, cobranças,
despesas, pagamentos, juros e capital reembolsado ficam
separados. Não calcula impostos, não converte moedas e não
substitui um plano contabilístico validado.
</p>
<div className="my-3 flex flex-wrap items-end gap-3 rounded border p-3">
<label className="grid gap-1 text-sm">
Importar plano financeiro InvestMoz (.json, até 60 KB)
<input
type="file"
accept="application/json,.json"
onChange={(event) => {
void importPlanFile(event.currentTarget.files?.[0]);
event.currentTarget.value = '';
}}
/>
</label>
<button
type="button"
className="rounded border px-3 py-2 text-sm"
onClick={exportPlanJson}
>
Exportar plano JSON
</button>
<span className="text-xs text-slate-600">
O ficheiro é lido localmente. A importação substitui os
pressupostos editáveis deste plano após validação do
formato.
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<label className="block text-sm font-medium">
Moeda (código ISO)
<input
list="plan-currency-options"
maxLength={3}
pattern="[A-Z]{3}"
value={currency}
onChange={(event) => {
const value = event.target.value.toUpperCase();
onCurrencyChange(value);
savePlan({ ...plan, currency: value });
}}
className="mt-1 w-full rounded border p-2"
aria-invalid={Boolean(
currency && !isSimulationCurrency(currency),
)}
/>
</label>
<datalist id="plan-currency-options">
{simulationCurrencyOptions.map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</datalist>
<p className="text-sm text-slate-700">
O plano financeiro aceita moedas ISO suportadas com duas
casas decimais. Se um investidor indicar uma moeda com
precisão diferente, mantenha o código e montante de origem
no registo; não os use nesta calculadora até a precisão
dessa moeda ser suportada.
</p>
<p className="text-sm text-slate-700">
A conversão só é usada neste plano quando escolher uma moeda
de apresentação diferente. Introduza uma cotação documentada
por si; o portal não consulta nem valida taxas cambiais. Os
valores na moeda original são preservados.
</p>
<label className="block text-sm font-medium">
Moeda de apresentação (opcional)
<input
list="plan-presentation-currency-options"
maxLength={3}
pattern="[A-Z]{3}"
value={plan.presentationCurrency ?? ''}
onChange={(event) =>
update(
'presentationCurrency',
event.target.value.toUpperCase(),
)
}
className="mt-1 w-full rounded border p-2"
aria-invalid={Boolean(
plan.presentationCurrency &&
!isSimulationCurrency(plan.presentationCurrency),
)}
/>
</label>
<datalist id="plan-presentation-currency-options">
{simulationCurrencyOptions.map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</datalist>
{plan.presentationCurrency &&
plan.presentationCurrency !== currency && (
<>
<label className="block text-sm font-medium">
Taxa indicativa ({plan.presentationCurrency} por
1 {currency})
<input
type="number"
min="0.00000001"
step="0.00000001"
value={plan.fxRate ?? ''}
onChange={(event) =>
update('fxRate', event.target.value)
}
className="mt-1 w-full rounded border p-2"
aria-required="true"
/>
</label>
<label className="block text-sm font-medium">
Data efectiva da taxa
<input
type="date"
value={plan.fxDate ?? ''}
onChange={(event) =>
update('fxDate', event.target.value)
}
className="mt-1 w-full rounded border p-2"
aria-required="true"
/>
</label>
<label className="block text-sm font-medium">
Fonte ou referência da cotação
<input
value={plan.fxSource ?? ''}
maxLength={2000}
onChange={(event) =>
update('fxSource', event.target.value)
}
className="mt-1 w-full rounded border p-2"
aria-required="true"
/>
</label>
<p className="text-sm text-amber-900">
Indique a unidade de apresentação por 1 unidade
da moeda-base, a data e a referência. Esta
conversão é um pressuposto de planeamento; não é
uma taxa oficial, fiscal ou aduaneira validada.
</p>
</>
)}
<MoneyInput
label="Saldo de caixa inicial"
value={plan.openingCash}
allowNegative
onChange={(value) => update('openingCash', value)}
/>
<label className="block text-sm font-medium">
Horizonte (meses)
<input
type="number"
min="1"
max="60"
step="1"
value={String(plan.periods ?? 12)}
onChange={(event) =>
updatePeriods(Number(event.target.value))
}
className="mt-1 w-full rounded border p-2"
/>
</label>
</div>
<fieldset className="my-4 space-y-3">
<legend className="font-semibold">
Produtos ou serviços facturados por mês
</legend>
<button
type="button"
className="text-sm underline"
aria-pressed={plan.useProductInventorySchedule ?? false}
onClick={toggleProductInventorySchedule}
>
{plan.useProductInventorySchedule
? 'Usar existencias agregadas'
: 'Planear stock por produto'}
</button>
{plan.useProductInventorySchedule && (
<p role="note" className="text-sm">
Declare o stock inicial e as encomendas de cada produto.
A soma do stock inicial deve coincidir com Existencias
iniciais no fundo de maneio. A falta nao reduz as vendas
planeadas automaticamente.
</p>
)}
<button
type="button"
className="text-sm underline"
aria-pressed={plan.useSupplierPaymentTerms ?? false}
onClick={toggleSupplierPaymentTerms}
>
{plan.useSupplierPaymentTerms
? 'Usar pagamentos mensais manuais'
: 'Calcular pagamentos pelo prazo do fornecedor'}
</button>
{plan.useSupplierPaymentTerms && (
<p role="note" className="text-sm">
Cada encomenda e paga integralmente apos o prazo
declarado, contado desde a encomenda. A agenda manual de
pagamentos variaveis fica suspensa para evitar
duplicacao; valores vencidos depois do horizonte sao
apresentados como saldo por pagar.
</p>
)}
{plan.products.map((product, index) => (
<div key={index} className="space-y-3 rounded border p-3">
<div className="grid gap-2 sm:grid-cols-4">
<label className="block text-sm">
Produto/serviço
<input
value={product.label}
onChange={(event) =>
updateProduct(
index,
'label',
event.target.value,
)
}
className="mt-1 w-full rounded border p-2"
/>
</label>
<label className="block text-sm">
{product.monthlyUnitsByPeriod
? 'Unidades mensais base'
: 'Unidades por mes'}
<input
type="number"
min="0"
step="0.001"
value={product.monthlyUnits}
onChange={(event) =>
updateProduct(
index,
'monthlyUnits',
event.target.value,
)
}
className="mt-1 w-full rounded border p-2"
/>
</label>
<MoneyInput
label="Preço por unidade"
value={product.unitPrice}
onChange={(value) =>
updateProduct(index, 'unitPrice', value)
}
/>
<MoneyInput
label="Custo variável por unidade"
value={product.unitVariableCost}
onChange={(value) =>
updateProduct(
index,
'unitVariableCost',
value,
)
}
/>
</div>
{plan.useProductInventorySchedule && (
<div className="space-y-2 rounded border p-3">
<MoneyInput
label={`Existencias iniciais — ${product.label}`}
value={
product.productInventoryOpening ?? ''
}
onChange={(value) =>
savePlan({
...plan,
currency,
products: plan.products.map(
(row, productIndex) =>
productIndex === index
? {
...row,
productInventoryOpening:
value,
}
: row,
),
})
}
/>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{(
product.productInventoryPurchasesByPeriod ??
[]
)
.slice(0, plan.periods ?? 12)
.map((entry, month) => (
<div
key={`product-stock-${index}-${month}`}
className="rounded border p-2"
>
<MoneyInput
label={`Encomenda — mes ${month + 1}`}
value={entry.amount}
onChange={(value) =>
updateProductInventoryPurchase(
index,
month,
'amount',
value,
)
}
/>
<label className="block text-sm">
Prazo de recepcao
<select
className="mt-1 w-full rounded border p-2"
value={entry.leadMonths}
onChange={(event) =>
updateProductInventoryPurchase(
index,
month,
'leadMonths',
Number(
event.target
.value,
),
)
}
>
{Array.from(
{ length: 13 },
(_, lead) => (
<option
key={lead}
value={lead}
>
{lead}{' '}
mes(es)
</option>
),
)}
</select>
</label>
{plan.useSupplierPaymentTerms && (
<fieldset className="mt-2 space-y-2 text-sm">
<legend>
Parcelas ao
fornecedor
</legend>
{(
entry.paymentInstallments ?? [
{
lagMonths:
entry.paymentLagMonths ??
0,
percent:
'100',
},
]
).map(
(
part,
partIndex,
) => (
<div
key={
partIndex
}
className="grid grid-cols-[1fr_1fr_auto] gap-2"
>
<label>
Prazo
<select
className="mt-1 w-full rounded border p-2"
value={
part.lagMonths
}
onChange={(
event,
) =>
updateProductPaymentInstallment(
index,
month,
partIndex,
'lagMonths',
Number(
event
.target
.value,
),
)
}
>
{Array.from(
{
length: 13,
},
(
_,
lag,
) => (
<option
key={
lag
}
value={
lag
}
>
{
lag
}{' '}
mes(es)
</option>
),
)}
</select>
</label>
<label>
Percentagem
<input
className="mt-1 w-full rounded border p-2"
type="number"
min="0"
max="100"
step="0.01"
value={
part.percent
}
onChange={(
event,
) =>
updateProductPaymentInstallment(
index,
month,
partIndex,
'percent',
event
.target
.value,
)
}
/>
</label>
<button
type="button"
className="self-end rounded border px-2 py-2"
aria-label="Remover parcela"
onClick={() =>
removeProductPaymentInstallment(
index,
month,
partIndex,
)
}
>
Remover
</button>
</div>
),
)}
<button
type="button"
className="rounded border px-2 py-1"
disabled={
(entry
.paymentInstallments
?.length ??
1) >= 12
}
onClick={() =>
addProductPaymentInstallment(
index,
month,
)
}
>
Adicionar parcela
</button>
</fieldset>
)}
</div>
))}
</div>
</div>
)}
<button
type="button"
className="text-sm underline"
onClick={() =>
toggleProductSchedule(
index,
'monthlyUnitsByPeriod',
)
}
>
{product.monthlyUnitsByPeriod
? 'Usar unidades constantes'
: 'Planear unidades por mes'}
</button>
{product.monthlyUnitsByPeriod && (
<>
<p role="note" className="text-sm">
Meses fora do horizonte ficam guardados. Ao
ampliar o horizonte, novos meses usam as
unidades base; ajuste o plano mensal.
</p>
<div className="grid gap-2 sm:grid-cols-3 lg:grid-cols-4">
{product.monthlyUnitsByPeriod
.slice(0, plan.periods ?? 12)
.map((units, month) => (
<label
key={month}
className="block text-sm"
>
Unidades - mes {month + 1}
<input
type="number"
min="0"
step="0.001"
value={units}
onChange={(event) =>
updateProductSchedule(
index,
'monthlyUnitsByPeriod',
month,
event.target.value,
)
}
className="mt-1 w-full rounded border p-2"
/>
</label>
))}
</div>
</>
)}
<button
type="button"
className="text-sm underline"
onClick={() =>
toggleProductSchedule(
index,
'unitPriceByPeriod',
)
}
>
{product.unitPriceByPeriod
? 'Usar precos constantes'
: 'Planear precos por mes'}
</button>
{product.unitPriceByPeriod && (
<div className="grid gap-2 sm:grid-cols-3 lg:grid-cols-4">
{product.unitPriceByPeriod
.slice(0, plan.periods ?? 12)
.map((price, month) => (
<MoneyInput
key={month}
label={`Preco por unidade - mes ${month + 1}`}
value={price}
onChange={(value) =>
updateProductSchedule(
index,
'unitPriceByPeriod',
month,
value,
)
}
/>
))}
</div>
)}
<button
type="button"
className="text-sm underline"
onClick={() =>
toggleProductSchedule(
index,
'unitVariableCostByPeriod',
)
}
>
{product.unitVariableCostByPeriod
? 'Usar custos unitarios constantes'
: 'Planear custos unitarios por mes'}
</button>
{product.unitVariableCostByPeriod && (
<div className="grid gap-2 sm:grid-cols-3 lg:grid-cols-4">
{product.unitVariableCostByPeriod
.slice(0, plan.periods ?? 12)
.map((cost, month) => (
<MoneyInput
key={month}
label={`Custo por unidade - mes ${month + 1}`}
value={cost}
onChange={(value) =>
updateProductSchedule(
index,
'unitVariableCostByPeriod',
month,
value,
)
}
/>
))}
</div>
)}
{!product.monthlyUnitsByPeriod &&
(product.unitPriceByPeriod ||
product.unitVariableCostByPeriod) && (
<p role="note" className="text-sm">
Meses fora do horizonte ficam guardados. Ao
ampliar o horizonte, novos meses usam os
valores-base.
</p>
)}
</div>
))}
<button
type="button"
className="text-sm underline"
onClick={() =>
savePlan({
...plan,
currency,
products: [
...plan.products,
{
label: `Produto/serviço ${plan.products.length + 1}`,
monthlyUnits: '0',
unitPrice: '0',
unitVariableCost: '0',
...(plan.useProductInventorySchedule
? {
productInventoryOpening: '',
productInventoryPurchasesByPeriod:
Array.from(
{ length: validHorizon },
() => ({
amount: '0',
leadMonths: 0,
...(plan.useSupplierPaymentTerms
? {
paymentLagMonths: 0,
paymentInstallments:
[
{
lagMonths: 0,
percent:
'100',
},
],
}
: {}),
}),
),
}
: {}),
},
],
})
}
>
Adicionar produto ou serviço
</button>
</fieldset>
<div className="grid gap-4 md:grid-cols-2">
<fieldset className="grid gap-2 sm:grid-cols-2">
<legend className="font-semibold">
Operação, custos e pagamentos mensais
</legend>
<label className="block text-sm font-medium">
Prazo entre facturação e cobrança das vendas
<select
value={plan.salesCollectionLagMonths ?? ''}
onChange={(event) =>
update(
'salesCollectionLagMonths',
event.target.value === ''
? null
: Number(event.target.value),
)
}
className="mt-1 w-full rounded border p-2"
>
<option value="">
Usar cobranças mensais declaradas
</option>
{Array.from({ length: 13 }, (_, month) => (
<option key={month} value={month}>
{month} mês(es)
</option>
))}
</select>
</label>
{plan.salesCollectionLagMonths !== null &&
plan.salesCollectionLagMonths !== undefined && (
<label className="block text-sm font-medium">
Percentagem da facturação cobrada no prazo
<input
type="text"
inputMode="decimal"
maxLength={16}
value={plan.salesCollectionPercent ?? '100'}
onChange={(event) =>
update(
'salesCollectionPercent',
event.target.value,
)
}
className="mt-1 w-full rounded border p-2"
aria-describedby="sales-collection-percent-help"
/>
<span
id="sales-collection-percent-help"
className="block text-xs font-normal"
>
Introduza 0–100. O restante permanece como
valor por cobrar; isto não prevê recuperação
posterior.
</span>
</label>
)}
{plan.salesCollectionLagMonths !== null &&
plan.salesCollectionLagMonths !== undefined && (
<div className="space-y-2 sm:col-span-2">
<p className="text-sm font-medium">
Recuperação de vendas vencidas
</p>
{(plan.salesRecoverySchedule ?? []).map(
(entry, index) => (
<div
className="flex gap-2"
key={`recovery-${index}`}
>
<label className="text-sm">
Mês
<select
className="ml-2 rounded border p-2"
value={entry.month}
onChange={(event) =>
updateSalesRecovery(
index,
'month',
Number(
event.target
.value,
),
)
}
>
{Array.from(
{
length:
plan.periods ??
12,
},
(_, month) => (
<option
key={month + 1}
value={
month + 1
}
>
{month + 1}
</option>
),
)}
</select>
</label>
<label className="text-sm">
% do saldo vencido
<input
className="ml-2 w-24 rounded border p-2"
inputMode="decimal"
value={entry.percent}
onChange={(event) =>
updateSalesRecovery(
index,
'percent',
event.target.value,
)
}
/>
</label>
<button
type="button"
className="rounded border px-2"
onClick={() =>
update(
'salesRecoverySchedule',
(
plan.salesRecoverySchedule ??
[]
).filter(
(_, row) =>
row !== index,
),
)
}
>
Remover
</button>
</div>
),
)}
<p className="text-xs">
Cada percentagem aplica-se ao saldo vencido
ainda em aberto no mês indicado, depois da
cobrança inicial. Sem agenda, não se presume
recuperação.
</p>
{(plan.salesRecoverySchedule ?? []).length <
60 && (
<button
type="button"
className="rounded border px-3 py-1"
onClick={() =>
update('salesRecoverySchedule', [
...(plan.salesRecoverySchedule ??
[]),
{ month: 1, percent: '0' },
])
}
>
Adicionar recuperação
</button>
)}
</div>
)}
<MoneyInput
label={
plan.salesCollectionLagMonths === null ||
plan.salesCollectionLagMonths === undefined
? 'Cobranças recebidas por mês'
: 'Outras cobranças mensais'
}
value={plan.monthlyCollections}
onChange={(value) =>
update('monthlyCollections', value)
}
/>
<p className="text-muted-foreground text-sm">
{plan.salesCollectionLagMonths === null ||
plan.salesCollectionLagMonths === undefined
? 'Modo manual: este valor representa todas as cobranças mensais e não é reconciliado automaticamente com a facturação.'
: `Modo por prazo: ${plan.salesCollectionPercent ?? '100'}% da facturação vence após o prazo indicado; o restante fica em aberto, excepto recuperações declaradas acima sobre saldos vencidos. O campo acima soma outras cobranças.`}
</p>
{plan.useProductInventorySchedule ? (
<p role="note" className="text-sm sm:col-span-2">
As compras agregadas ficam inactivas enquanto o
stock estiver planeado por produto.
</p>
) : (
<>
<div className="sm:col-span-2">
<button
type="button"
className="text-sm underline"
aria-pressed={
plan.useInventoryPurchaseSchedule ??
false
}
onClick={toggleInventoryPurchaseSchedule}
>
{plan.useInventoryPurchaseSchedule
? 'Usar compras e prazo constantes'
: 'Planear compras e prazos por mês'}
</button>
</div>
{plan.useInventoryPurchaseSchedule ? (
<div className="space-y-3 sm:col-span-2">
{(plan.inventoryPurchaseSchedule ?? [])
.slice(0, plan.periods ?? 12)
.map((entry, index) => (
<div
key={`inventory-order-${index}`}
className="grid gap-2 rounded border p-3 sm:grid-cols-2"
>
<MoneyInput
label={`Encomenda para existências — mês ${index + 1}`}
value={entry.amount}
onChange={(value) =>
updateInventoryPurchase(
index,
'amount',
value,
)
}
/>
<label className="block text-sm font-medium">
Prazo até à recepção — mês{' '}
{index + 1}
<select
value={entry.leadMonths}
onChange={(event) =>
updateInventoryPurchase(
index,
'leadMonths',
Number(
event.target
.value,
),
)
}
className="mt-1 w-full rounded border p-2"
>
{Array.from(
{ length: 13 },
(_, month) => (
<option
key={month}
value={
month
}
>
{month}{' '}
mês(es)
</option>
),
)}
</select>
</label>
</div>
))}
</div>
) : (
<>
<MoneyInput
label="Compras mensais para existências"
value={
plan.monthlyInventoryPurchases ?? ''
}
onChange={(value) =>
update(
'monthlyInventoryPurchases',
value,
)
}
/>
<label className="block text-sm font-medium">
Prazo de entrega das compras (meses)
<input
type="number"
min="0"
max="12"
step="1"
value={String(
plan.inventoryPurchaseLeadMonths ??
0,
)}
onChange={(event) =>
update(
'inventoryPurchaseLeadMonths',
Number(event.target.value),
)
}
className="mt-1 w-full rounded border p-2"
/>
</label>
</>
)}
<p className="text-xs text-slate-600 sm:col-span-2">
As compras são encomendas e cada prazo declarado
afecta o mês da recepção; pagamentos de compras
continuam a ser um fluxo de caixa separado.
Encomendas ainda não recebidas aparecem como
mercadoria em trânsito. Se faltar stock, reveja
compras ou vendas: a facturação introduzida não
é reduzida automaticamente.
</p>
</>
)}
<MoneyInput
label="Despesas operacionais reconhecidas"
value={plan.monthlyOperatingExpenses}
onChange={(value) =>
update('monthlyOperatingExpenses', value)
}
/>
<MoneyInput
label="Pagamento de despesas operacionais"
value={plan.monthlyOperatingPayments}
onChange={(value) =>
update('monthlyOperatingPayments', value)
}
/>
<MoneyInput
label="Custo mensal de pessoal"
value={plan.monthlyPayrollExpense}
onChange={(value) =>
update('monthlyPayrollExpense', value)
}
/>
<MoneyInput
label="Pagamento mensal de pessoal"
value={plan.monthlyPayrollPayments}
onChange={(value) =>
update('monthlyPayrollPayments', value)
}
/>
{plan.useSupplierPaymentTerms && (
<p role="note" className="text-sm sm:col-span-2">
Os pagamentos de compras sao calculados pelas
encomendas e prazos acima; o valor mensal manual
fica suspenso para evitar contar os mesmos
pagamentos duas vezes.
</p>
)}
{moneyScheduleFields
.filter(
({ key }) =>
!plan.useSupplierPaymentTerms ||
key !== 'monthlyVariableCostPaymentsByPeriod',
)
.map(({ key, baseKey, label }) => (
<div key={key} className="sm:col-span-2">
<button
type="button"
className="text-sm underline"
onClick={() =>
toggleMoneySchedule(key, baseKey)
}
>
{plan[key]
? `Usar valor constante: ${label.toLowerCase()}`
: `Planear por mês: ${label.toLowerCase()}`}
</button>
{key ===
'monthlyVariableCostPaymentsByPeriod' &&
!plan[key] && (
<MoneyInput
label={label}
value={plan[baseKey]}
onChange={(value) =>
update(baseKey, value)
}
/>
)}{' '}
{plan[key] && (
<div className="mt-2 grid gap-2 sm:grid-cols-3 lg:grid-cols-4">
{plan[key]!.slice(
0,
plan.periods ?? 12,
).map((amount, month) => (
<MoneyInput
key={month}
label={`${label} - mês ${month + 1}`}
value={amount}
onChange={(value) =>
updateMoneySchedule(
key,
month,
value,
)
}
/>
))}
</div>
)}
</div>
))}
</fieldset>
<fieldset className="grid gap-2 sm:grid-cols-2">
<legend className="font-semibold">
Investimento, fundo de maneio e financiamento inicial
</legend>
<MoneyInput
label="Aquisição de activos"
value={plan.assetInvestment}
onChange={(value) => update('assetInvestment', value)}
/>
<MoneyInput
label="Existências iniciais"
value={plan.inventory}
onChange={(value) => update('inventory', value)}
/>
<MoneyInput
label="Valores a receber"
value={plan.receivables}
onChange={(value) => update('receivables', value)}
/>
<label className="block text-sm font-medium">
Mês previsto para cobrar valores a receber de abertura
<select
value={plan.openingReceivablesCollectionMonth ?? ''}
onChange={(event) =>
update(
'openingReceivablesCollectionMonth',
event.target.value === ''
? null
: Number(event.target.value),
)
}
className="mt-1 w-full rounded border p-2"
>
<option value="">Sem previsão de cobrança</option>
{Array.from({ length: 60 }, (_, index) => (
<option key={index + 1} value={index + 1}>
Mês {index + 1}
</option>
))}
</select>
</label>
{plan.openingReceivablesCollectionMonth !== null &&
plan.openingReceivablesCollectionMonth !==
undefined && (
<label className="block text-sm font-medium">
Percentagem prevista a cobrar
<input
type="text"
inputMode="decimal"
maxLength={16}
value={
plan.openingReceivablesCollectionPercent ??
'100'
}
onChange={(event) =>
update(
'openingReceivablesCollectionPercent',
event.target.value,
)
}
className="mt-1 w-full rounded border p-2"
/>
</label>
)}
<div className="space-y-3 sm:col-span-2">
<div>
<h3 className="font-medium">
Parcelas adicionais de recebíveis de abertura
</h3>
<p className="text-xs text-slate-600">
Declare cada mês e a percentagem do saldo
inicial a cobrar. Inclua a cobrança única acima
na soma: o total não pode exceder 100%. Sem
parcela declarada, não se presume recebimento.
</p>
</div>
{(plan.openingReceivablesInstallments ?? []).map(
(installment, index) => (
<div
key={`opening-receivable-${index}`}
className="grid gap-2 rounded border p-3 sm:grid-cols-[1fr_1fr_auto]"
>
<label className="block text-sm font-medium">
Mês da parcela {index + 1}
<select
value={installment.month}
onChange={(event) =>
updateReceivableInstallment(
index,
'month',
Number(event.target.value),
)
}
className="mt-1 w-full rounded border p-2"
>
{Array.from(
{ length: 60 },
(_, month) => month + 1,
).map((month) => (
<option
key={month}
value={month}
>
Mês {month}
</option>
))}
</select>
</label>
<label className="block text-sm font-medium">
Percentagem do saldo inicial
<input
type="text"
inputMode="decimal"
maxLength={16}
value={installment.percent}
onChange={(event) =>
updateReceivableInstallment(
index,
'percent',
event.target.value,
)
}
className="mt-1 w-full rounded border p-2"
/>
</label>
<button
type="button"
className="self-end rounded border px-3 py-2 text-sm"
aria-label={`Remover parcela de recebíveis ${index + 1}`}
onClick={() =>
removeReceivableInstallment(index)
}
>
Remover
</button>
</div>
),
)}
<button
type="button"
className="rounded border px-3 py-2 text-sm"
disabled={
(plan.openingReceivablesInstallments ?? [])
.length >= 60
}
onClick={addReceivableInstallment}
>
Acrescentar parcela
</button>
</div>
<MoneyInput
label="Valores a pagar"
value={plan.payables}
onChange={(value) => update('payables', value)}
/>
<div className="space-y-3 sm:col-span-2">
<div>
<h3 className="font-medium">
Liquidação de valores a pagar de abertura
</h3>
<p className="text-xs text-slate-600">
Opcional. Declare o mês e a percentagem do saldo
inicial a pagar. A soma não pode exceder 100%;
sem parcelas, não se presume pagamento e o saldo
continua visível como pendente no fim. Estes
pagamentos reduzem caixa, não o resultado do
período.
</p>
</div>
{(plan.openingPayablesInstallments ?? []).map(
(installment, index) => (
<div
key={`opening-payable-${index}`}
className="grid gap-2 rounded border p-3 sm:grid-cols-[1fr_1fr_auto]"
>
<label className="block text-sm font-medium">
Mês do pagamento {index + 1}
<select
value={installment.month}
onChange={(event) =>
updatePayableInstallment(
index,
'month',
Number(event.target.value),
)
}
className="mt-1 w-full rounded border p-2"
>
{Array.from(
{ length: plan.periods ?? 12 },
(_, month) => month + 1,
).map((month) => (
<option
key={month}
value={month}
>
Mês {month}
</option>
))}
</select>
</label>
<label className="block text-sm font-medium">
Percentagem do saldo inicial
<input
type="text"
inputMode="decimal"
maxLength={16}
value={installment.percent}
onChange={(event) =>
updatePayableInstallment(
index,
'percent',
event.target.value,
)
}
className="mt-1 w-full rounded border p-2"
/>
</label>
<button
type="button"
className="self-end rounded border px-3 py-2 text-sm"
aria-label={`Remover liquidação de valores a pagar ${index + 1}`}
onClick={() =>
removePayableInstallment(index)
}
>
Remover
</button>
</div>
),
)}
<button
type="button"
className="rounded border px-3 py-2 text-sm"
disabled={
(plan.openingPayablesInstallments ?? [])
.length >= 60
}
onClick={addPayableInstallment}
>
Acrescentar pagamento
</button>
</div>
<MoneyInput
label="Capital próprio"
value={plan.equity}
onChange={(value) => update('equity', value)}
/>
<MoneyInput
label="Suprimentos"
value={plan.shareholderLoans}
onChange={(value) => update('shareholderLoans', value)}
/>
<MoneyInput
label="Empréstimo recebido"
value={plan.bankLoanProceeds}
onChange={(value) => update('bankLoanProceeds', value)}
/>
<MoneyInput
label="Subvenções"
value={plan.grants}
onChange={(value) => update('grants', value)}
/>
</fieldset>
<fieldset className="grid gap-2 sm:grid-cols-2">
<legend className="font-semibold">
Depreciação contabilística e dívida mensal
</legend>
<MoneyInput
label="Base depreciável contabilística"
value={plan.assetDepreciation}
onChange={(value) => update('assetDepreciation', value)}
/>
<label className="block text-sm">
Vida útil (meses)
<input
type="number"
min="1"
max="600"
value={plan.depreciationLifeMonths}
onChange={(event) =>
update(
'depreciationLifeMonths',
Number(event.target.value),
)
}
className="mt-1 w-full rounded border p-2"
/>
</label>
<MoneyInput
label="Despesa de juros reconhecida"
value={plan.monthlyInterestExpense}
onChange={(value) =>
update('monthlyInterestExpense', value)
}
/>
<MoneyInput
label="Pagamento de juros"
value={plan.monthlyInterestPayment}
onChange={(value) =>
update('monthlyInterestPayment', value)
}
/>
<MoneyInput
label="Reembolso de capital"
value={plan.monthlyPrincipalRepayment}
onChange={(value) =>
update('monthlyPrincipalRepayment', value)
}
/>
</fieldset>
</div>
<details className="mt-4 rounded border p-3">
<summary className="cursor-pointer font-semibold">
Comparar cenários financeiros
</summary>
<p className="my-2 text-sm">
“Favorável” e “desfavorável” são rótulos de cenários, não
previsões. Defina como variam volume, preço, custo variável,
despesas/pessoal e cobranças declaradas face ao cenário
base. Com cobrança de vendas por prazo, a facturação
ajustada determina esses recebimentos; esta variação
aplica-se apenas às outras cobranças. Sem variações
introduzidas, não há comparação.
</p>
<div className="grid gap-4 md:grid-cols-2">
{(
[
['favorable', 'Favorável'],
['unfavorable', 'Desfavorável'],
] as const
).map(([scenario, title]) => (
<fieldset
key={scenario}
className="grid gap-2 sm:grid-cols-2"
>
<legend className="font-semibold">
{title} (%)
</legend>
{(
[
[
'unitVolumePercent',
'Variação de unidades',
],
['unitPricePercent', 'Variação do preço'],
[
'unitCostPercent',
'Variação do custo variável',
],
[
'operatingCostsPercent',
'Variação de despesas e pessoal',
],
[
'cashCollectionsPercent',
'Variação das cobranças',
],
] as const
).map(([key, label]) => (
<label key={key} className="block text-sm">
{label}
<input
type="number"
min="-100"
step="0.01"
value={
scenarioAdjustments[scenario][key]
}
onChange={(event) =>
updateScenario(
scenario,
key,
event.target.value,
)
}
className="mt-1 w-full rounded border p-2"
/>
</label>
))}
</fieldset>
))}
</div>
{scenarioError && (
<p role="alert" className="mt-2 text-sm text-red-800">
{scenarioError}
</p>
)}
{scenarioIncomplete && (
<output className="mt-2 block text-sm">
Complete todos os pressupostos dos dois cenários ou
limpe as variações para exportar o relatório.
</output>
)}
{scenarios && isSimulationCurrency(currency) && (
<div className="mt-3 overflow-x-auto">
<table className="min-w-full text-sm">
<caption className="sr-only">
Comparação de resultados por cenário
</caption>
<thead>
<tr>
{[
'Cenário',
'Variações declaradas',
'Facturação',
'Resultado antes impostos',
'Caixa final',
'Existências finais',
'Falta de stock',
].map((label) => (
<th
key={label}
className="p-2 text-left"
>
{label}
</th>
))}
</tr>
</thead>
<tbody>
<tr className="border-t">
<th scope="row" className="p-2">
Base
</th>
<td className="p-2">Sem variação</td>
<td className="p-2">
{scenarios.base.totalRevenue}
</td>
<td className="p-2">
{scenarios.base.totalProfitBeforeTax}
</td>
<td className="p-2">
{scenarios.base.endingCash}
</td>
<td className="p-2">
{scenarios.base.inventoryProjection
.endingInventory ??
'indeterminadas'}
</td>
<td className="p-2">
{scenarios.base.inventoryProjection
.totalShortfall ?? 'indeterminada'}
</td>
</tr>
{(
[
['Favorável', scenarios.favorable],
['Desfavorável', scenarios.unfavorable],
] as const
).map(([label, scenario]) => (
<tr key={label} className="border-t">
<th scope="row" className="p-2">
{label}
</th>
<td className="p-2">
Unidades{' '}
{
scenario.adjustments
.unitVolumePercent
}
%; preço{' '}
{
scenario.adjustments
.unitPricePercent
}
%; custo variável{' '}
{
scenario.adjustments
.unitCostPercent
}
%; compras de stock ajustadas por
volume/custo; despesas{' '}
{
scenario.adjustments
.operatingCostsPercent
}
%; cobranças{' '}
{
scenario.adjustments
.cashCollectionsPercent
}
%
</td>
<td className="p-2">
{scenario.result.totalRevenue}
</td>
<td className="p-2">
{
scenario.result
.totalProfitBeforeTax
}
</td>
<td className="p-2">
{scenario.result.endingCash}
</td>
<td className="p-2">
{scenario.result.inventoryProjection
.endingInventory ??
'indeterminadas'}
</td>
<td className="p-2">
{scenario.result.inventoryProjection
.totalShortfall ??
'indeterminada'}
</td>
</tr>
))}
</tbody>
</table>
<p className="mt-2 text-xs">
Valores em {currency}, antes de impostos. As
alterações são pressupostos do utilizador; as
componentes fiscais mantêm-se indeterminadas.
</p>
</div>
)}
</details>
{error && (
<p role="alert" className="my-3 text-sm text-red-800">
{error}
</p>
)}
{result && isSimulationCurrency(currency) && (
<div className="mt-4 space-y-3">
<h4 className="font-semibold">Resumo ({currency})</h4>
<button
type="button"
disabled={Boolean(scenarioError || scenarioIncomplete)}
className="rounded border px-3 py-2 text-sm focus-visible:outline-2 disabled:opacity-50"
onClick={() => {
const blob = new Blob(
[exportBusinessPlanCsv({ ...plan, currency })],
{ type: 'text/csv;charset=utf-8' },
);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'plano-financeiro.csv';
link.click();
window.setTimeout(
() => URL.revokeObjectURL(url),
1000,
);
}}
>
Exportar plano em CSV
</button>
<p
role={
result.fundingStatus === 'balanced'
? 'status'
: 'alert'
}
className={
result.fundingStatus === 'balanced'
? 'rounded bg-emerald-50 p-3 text-sm'
: 'rounded bg-amber-50 p-3 text-sm'
}
>
{result.fundingStatus === 'balanced'
? 'As fontes de financiamento reconciliam com os usos iniciais.'
: result.fundingStatus === 'shortfall'
? `Falta financiar ${result.fundingGap} ${currency} dos usos iniciais.`
: `As fontes excedem os usos iniciais em ${result.fundingGap.replace('-', '')} ${currency}.`}
</p>
<dl className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{[
['Fundo de maneio líquido', result.workingCapital],
['Usos iniciais', result.initialUses],
['Fontes de financiamento', result.financing],
[
'Diferenca entre usos e fontes',
result.fundingGap,
],
['Facturação no horizonte', result.totalRevenue],
[
'Cobranças no horizonte',
result.totalCashCollections,
],
[
'Pagamentos de valores a pagar de abertura',
result.totalOpeningPayablesPayments,
],
[
'Valores a pagar de abertura pendentes no fim',
result.openingPayablesAtEnd,
],
[
'Cobrança prevista de valores a receber de abertura',
result.totalOpeningReceivablesCollections,
],
[
'Valores de abertura ainda por receber',
result.openingReceivablesAtEnd,
],
...(result.totalSalesCollections === null
? []
: [
[
'Cobranças da facturação modelada',
result.totalSalesCollections,
],
[
'Cobranças iniciais das vendas',
result.totalScheduledSalesCollections,
],
[
'Recuperações de vendas vencidas',
result.totalSalesRecoveryCollections,
],
[
'Facturação por cobrar no fim',
result.salesReceivablesAtEnd,
],
]),
[
'Resultado antes de impostos',
result.totalProfitBeforeTax,
],
['Caixa final projectada', result.endingCash],
].map(([label, value]) => (
<div
key={label}
className="rounded bg-slate-50 p-2"
>
<dt className="text-sm">{label}</dt>
<dd className="font-semibold">{value}</dd>
</div>
))}
</dl>
{result.openingReceivablesInstallments.length > 0 && (
<ul
aria-label="Parcelas previstas de recebíveis de abertura"
className="space-y-1 text-sm"
>
{result.openingReceivablesInstallments.map(
(installment, index) => (
<li key={`${installment.month}-${index}`}>
Mês {installment.month}:{' '}
{installment.percent}% do saldo inicial
({installment.amount} {currency})
</li>
),
)}
</ul>
)}
{result.openingPayablesInstallments.length > 0 && (
<ul
aria-label="Pagamentos previstos de valores a pagar de abertura"
className="space-y-1 text-sm"
>
{result.openingPayablesInstallments.map(
(installment, index) => (
<li key={`${installment.month}-${index}`}>
Mês {installment.month}:{' '}
{installment.percent}% do saldo inicial
({installment.amount} {currency})
</li>
),
)}
</ul>
)}
<p className="text-sm">
Fontes iniciais = capital próprio + suprimentos +
empréstimos recebidos + subvenções. Usos iniciais =
activos + fundo de maneio líquido. Juros são despesa;
reembolso de capital é fluxo de caixa, não custo
operacional.
</p>
{fiscalBridge && (
<section
className="space-y-2 rounded border border-amber-300 bg-amber-50 p-3"
aria-labelledby="financial-fiscal-bridge-title"
>
<h5
id="financial-fiscal-bridge-title"
className="font-semibold"
>
Ponte contabilístico-fiscal — incompleta
</h5>
<p className="text-sm">
Resultado contabilístico antes de impostos:{' '}
{fiscalBridge.accountingProfitBeforeTax}{' '}
{currency}. Matéria colectável: indeterminada;
não se presume que as despesas sejam fiscalmente
dedutíveis.
</p>
<div className="overflow-x-auto">
<table className="min-w-full text-xs">
<caption className="sr-only">
Linhas contabilísticas propostas para
classificação fiscal
</caption>
<thead>
<tr>
{[
'Origem',
'Efeito contabilístico',
'Total no período',
'Tratamento fiscal',
].map((label) => (
<th
key={label}
className="p-2 text-left"
>
{label}
</th>
))}
</tr>
</thead>
<tbody>
{fiscalBridge.accountingLines.map(
(line) => (
<tr
key={line.code}
className="border-t"
>
<th
scope="row"
className="p-2"
>
{line.origin}
</th>
<td className="p-2">
{line.accountingEffect ===
'income'
? 'Rendimento'
: 'Despesa'}
; por mês:{' '}
{line.monthlyAmounts
.map(
(item) =>
`${item.period}: ${item.amount}`,
)
.join(' · ')}
</td>
<td className="p-2">
{line.total} {currency}
</td>
<td className="p-2">
Por classificar
</td>
</tr>
),
)}
</tbody>
</table>
</div>
<p className="text-xs">
Cobranças e pagamentos são apresentados
separadamente no calendário de caixa. Reembolsos
de capital não entram como despesa. Não são
acrescentados valores fiscais zero nem
pagamentos estimados.
</p>
</section>
)}
{result.inventoryProjection.status === 'indeterminate' ? (
<output className="block rounded bg-amber-50 p-3 text-sm">
Existências finais indeterminadas: indique compras
mensais para calcular saldo e falta de stock.
Pagamentos de compras são um fluxo de caixa separado
e não substituem este pressuposto.
</output>
) : (
<output
className={`block rounded p-3 text-sm ${result.inventoryProjection.status === 'estimated_with_shortfall' ? 'bg-amber-50' : 'bg-emerald-50'}`}
>
Existências finais estimadas:{' '}
{result.inventoryProjection.endingInventory}{' '}
{currency}; prazo de entrega:{' '}
{result.inventoryProjection.leadMonths === null
? 'variável por mês'
: `${result.inventoryProjection.leadMonths} mês(es)`}
. Mercadoria em trânsito no fim do horizonte:{' '}
{result.inventoryProjection.inTransitAtEnd}{' '}
{currency}. Falta total de stock estimada:{' '}
{result.inventoryProjection.totalShortfall}{' '}
{currency}. As vendas planeadas permanecem
inalteradas e devem ser revistas se houver falta.
</output>
)}
<div className="overflow-x-auto">
<table className="min-w-full text-xs">
<caption className="sr-only">
Encomendas, recepções, mercadoria em trânsito,
consumo, falta e saldo de existências por mês
</caption>
<thead>
<tr>
{[
'Mês',
'Saldo inicial',
'Encomendas',
'Prazo (meses)',
'Recepções',
'Em trânsito',
'Consumo',
'Falta de stock',
'Saldo final',
].map((label) => (
<th
key={label}
className="p-2 text-left"
>
{label}
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.map((row) => (
<tr key={row.month} className="border-t">
<th scope="row" className="p-2">
{row.month}
</th>
<td className="p-2">
{row.inventoryOpening ??
'indeterminado'}
</td>
<td className="p-2">
{row.inventoryPurchases ??
'indeterminadas'}
</td>
<td className="p-2">
{row.inventoryLeadMonths ??
'indeterminado'}
</td>
<td className="p-2">
{row.inventoryReceipts ??
'indeterminadas'}
</td>
<td className="p-2">
{row.inventoryInTransit ??
'indeterminadas'}
</td>
<td className="p-2">
{row.inventoryConsumption}
</td>
<td className="p-2">
{row.inventoryShortfall ??
'indeterminada'}
</td>
<td className="p-2">
{row.inventoryClosing ??
'indeterminadas'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{result.inventoryProjection.productSchedules && (
<section className="space-y-3 rounded border p-3">
<h3 className="font-semibold">
Existencias por produto
</h3>
{result.inventoryProjection.productSchedules.map(
(product) => (
<details
key={product.label}
className="rounded border p-2"
>
<summary className="cursor-pointer">
{product.label}: stock final{' '}
{product.endingInventory} {currency}
; falta {product.totalShortfall}{' '}
{currency}
</summary>
<div className="overflow-x-auto">
<table className="min-w-full text-xs">
<thead>
<tr>
{[
'Mes',
'Inicial',
'Encomendas',
'Prazo',
'Recepcoes',
'Em transito',
'Consumo',
'Falta',
'Final',
].map((label) => (
<th
key={label}
className="p-2 text-left"
>
{label}
</th>
))}
</tr>
</thead>
<tbody>
{product.monthlyRows.map(
(row) => (
<tr
key={row.month}
className="border-t"
>
<th
scope="row"
className="p-2"
>
{row.month}
</th>
<td className="p-2">
{
row.opening
}
</td>
<td className="p-2">
{
row.purchases
}
</td>
<td className="p-2">
{
row.leadMonths
}
</td>
<td className="p-2">
{
row.receipts
}
</td>
<td className="p-2">
{
row.inTransit
}
</td>
<td className="p-2">
{
row.consumption
}
</td>
<td className="p-2">
{
row.shortfall
}
</td>
<td className="p-2">
{
row.closing
}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
{product.paymentSchedule && (
<div className="mt-3 overflow-x-auto">
<h4 className="font-medium">
Pagamentos declarados ao
fornecedor
</h4>
<table className="min-w-full text-xs">
<thead>
<tr>
<th className="p-2 text-left">
Encomenda
</th>
<th className="p-2 text-left">
Pagamento
</th>
<th className="p-2 text-left">
Parcela
</th>
<th className="p-2 text-left">
Montante
</th>
<th className="p-2 text-left">
Estado
</th>
</tr>
</thead>
<tbody>
{product.paymentSchedule.map(
(payment) => (
<tr
key={`${payment.orderMonth}-${payment.paymentMonth ?? 'unallocated'}-${payment.amount}`}
className="border-t"
>
<td className="p-2">
Mes{' '}
{
payment.orderMonth
}
</td>
<td className="p-2">
{payment.paymentMonth ===
null
? 'Sem prazo declarado'
: `Mes ${payment.paymentMonth}`}
</td>
<td className="p-2">
{payment.percent ===
'remaining'
? 'Saldo nao calendarizado'
: `${payment.percent}%`}
</td>
<td className="p-2">
{
payment.amount
}{' '}
{
currency
}
</td>
<td className="p-2">
{payment.paidWithinHorizon
? 'Incluido no fluxo de caixa'
: payment.paymentMonth ===
null
? 'Saldo sem parcela calendarizada'
: 'Por pagar no fim do horizonte'}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)}
</details>
),
)}
</section>
)}{' '}
{result.supplierPaymentsOutstandingAtEnd !== null && (
<output className="block rounded bg-amber-50 p-3 text-sm">
Compras a fornecedores ainda por pagar no fim do
horizonte: {result.supplierPaymentsOutstandingAtEnd}{' '}
{currency}. Os pagamentos sao calculados pelas
encomendas e prazos declarados; nao representam
credito confirmado pelo fornecedor.
</output>
)}
{result.conversion && (
<>
<p className="rounded bg-sky-50 p-3 text-sm">
Conversão de apresentação: 1 {currency} ={' '}
{result.conversion.rate}{' '}
{result.conversion.currency}, em{' '}
{result.conversion.date}; fonte declarada:{' '}
{result.conversion.source}. Cotação introduzida
pelo utilizador e não verificada pelo portal;
resultado apenas indicativo para planeamento. Os
valores originais permanecem em {currency}.
</p>
<dl className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{[
[
'Fundo de maneio',
result.conversion.workingCapital,
],
[
'Usos iniciais',
result.conversion.initialUses,
],
[
'Fontes de financiamento',
result.conversion.financing,
],
[
'Estado da reconciliacao do financiamento',
result.conversion.fundingStatus,
],
[
'Diferenca entre usos e fontes',
result.conversion.fundingGap,
],
[
'Facturação',
result.conversion.totalRevenue,
],
[
'Cobranças no horizonte',
result.conversion.totalCashCollections,
],
[
'Cobrança de valores de abertura',
result.conversion
.totalOpeningReceivablesCollections,
],
[
'Valores de abertura por receber',
result.conversion
.openingReceivablesAtEnd,
],
[
'Resultado antes impostos',
result.conversion.totalProfitBeforeTax,
],
[
'Caixa final',
result.conversion.endingCash,
],
].map(([label, value]) => (
<div
key={label}
className="rounded bg-sky-50 p-2"
>
<dt className="text-sm">
{label} (
{result.conversion.currency})
</dt>
<dd className="font-semibold">
{value}
</dd>
</div>
))}
</dl>
</>
)}
<div className="overflow-x-auto">
<table className="min-w-full text-xs">
<caption className="sr-only">
Resultados financeiros mensais antes de impostos
</caption>
<thead>
<tr>
{[
'Mês',
'Facturação',
'Cobranças',
'Cobranças das vendas',
'Cobranças iniciais',
'Recuperações vencidas',
'Cobrança de valores de abertura',
'Valores de abertura por receber',
'Outras cobranças',
'Custos variáveis',
'Despesas/pessoal',
'Depreciação',
'Juros',
'Resultado antes de impostos',
'Pagamento de capital',
'Caixa final',
].map((label) => (
<th
key={label}
className="p-2 text-left"
>
{label}
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.map((row) => (
<tr key={row.month} className="border-t">
<th scope="row" className="p-2">
{row.month}
</th>
<td className="p-2">{row.revenue}</td>
<td className="p-2">
{row.cashCollections}
</td>
<td className="p-2">
{row.salesCollections ?? 'manual'}
</td>
<td className="p-2">
{row.salesScheduledCollections ??
'—'}
</td>
<td className="p-2">
{row.salesRecoveryCollections ??
'—'}
</td>
<td className="p-2">
{row.openingReceivablesCollections}
</td>
<td className="p-2">
{row.openingReceivablesBalance}
</td>
<td className="p-2">
{row.otherCollections ??
'incluídas'}
</td>
<td className="p-2">
{row.variableCost}
</td>
<td className="p-2">
{row.operatingExpenses} /{' '}
{row.payrollExpense}
</td>
<td className="p-2">
{row.depreciation}
</td>
<td className="p-2">
{row.interestExpense}
</td>
<td className="p-2">
{row.profitBeforeTax}
</td>
<td className="p-2">
{row.principalRepayment}
</td>
<td className="p-2">
{row.closingCash}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</details>
);
}