3153 lines
163 KiB
TypeScript
3153 lines
163 KiB
TypeScript
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>
|
||
);
|
||
}
|