Files
Investidor-V2/resources/js/pages/offers/preparation.tsx
T
2026-11-14 19:07:14 +02:00

548 lines
23 KiB
TypeScript

import { Head, router } from '@inertiajs/react';
import { useState } from 'react';
import {
TerritoryFields,
type TerritoryOption,
} from '@/components/investment-territory';
import OpportunityContactForm from '@/components/opportunity-contact-form';
import { modalityLabels } from '@/components/opportunity-search-filters';
import { createOperationKey } from '@/lib/operation-key';
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
import OfferEditorialReview from '@/components/offer-editorial-review';
import { type OfferEditorialRecord } from '@/lib/offer-editorial';
import OfferParticularsFields, {
type OfferParticulars,
} from '@/components/offer-particulars';
import OfferPrivateResources from '@/components/offer-private-resources';
import OfferConfidentialAccess from '@/components/offer-confidential-access';
import OfferOperations from '@/components/offer-operations';
import OfferNavigation from '@/components/offer-navigation';
type Offer = OfferParticulars & {
price_mode?: string | null;
amount?: string | null;
currency?: string | null;
contact_id?: string | null;
};
type Context = {
organization_id: string | null;
organization_name: string | null;
type: string;
};
type RecordData = OfferEditorialRecord & {
id: number;
reference: string;
etag: string;
state: string;
canEdit: boolean;
organization_id: string | null;
territory: TerritoryOption[];
contactLabel?: string | null;
values: {
title?: string;
description?: string;
opportunity_category: string;
private_offer?: Offer;
};
};
type Contact = { id: string; full_name: string };
const control = 'w-full rounded-md border bg-background p-2 text-foreground';
const button =
'rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50';
const emptyPath: TerritoryOption[] = [];
export default function OfferPreparation({
record,
contexts,
contacts,
contactInvestors,
sectors,
propertyTypes,
}: {
record: RecordData | null;
contexts: Context[];
contacts: Contact[];
contactInvestors: { id: string; legal_name: string }[];
sectors: { id: number; name: string }[];
propertyTypes: string[];
}) {
const [saved, setSaved] = useState(record);
const [creationKey] = useState(createOperationKey);
const [context, setContext] = useState<Context>(
record
? {
organization_id: record.organization_id,
organization_name: null,
type: record.values.opportunity_category,
}
: contexts[0],
);
const [title, setTitle] = useState(record?.values.title ?? '');
const [description, setDescription] = useState(
record?.values.description ?? '',
);
const [offer, setOffer] = useState<Offer>(
record?.values.private_offer ?? {
price_mode: null,
amount: null,
currency: null,
contact_id: null,
},
);
const [territory, setTerritory] = useState(record?.territory ?? emptyPath);
const [contactChoices, setContactChoices] = useState(() => {
const selected = record?.values.private_offer?.contact_id;
return selected && !contacts.some((contact) => contact.id === selected)
? [
...contacts,
{
id: selected,
full_name:
record?.contactLabel ?? 'Contacto indisponível',
},
]
: contacts;
});
const [dirty, setDirty] = useState(!record);
const [busy, setBusy] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [message, setMessage] = useState('');
const readOnly = saved !== null && !saved.canEdit;
function updateOffer(next: Offer) {
setOffer(next);
setDirty(true);
setConfirmed(false);
}
async function execute(submit: boolean) {
if (busy || readOnly || (submit && (!saved || dirty || !confirmed)))
return;
setBusy(true);
setErrors({});
setMessage('');
try {
const base = '/minhas-oportunidades/ofertas';
const result = await profileJson(
submit
? `${base}/${saved!.id}/submeter`
: saved
? `${base}/${saved.id}/guardar`
: `${base}/rascunhos`,
submit
? { etag: saved!.etag, confirm: confirmed }
: {
...(saved
? { etag: saved.etag }
: { creation_key: creationKey }),
title,
description,
opportunity_category: context.type,
organization_id: context.organization_id,
private_offer: offer,
province_id: territory[0]?.id ?? null,
district_id: territory[1]?.id ?? null,
admin_post_id: territory[2]?.id ?? null,
locality_id: territory[3]?.id ?? null,
reason: 'Preparação privada da oferta pelo promotor autorizado.',
},
);
setSaved(result.record);
setDirty(false);
setConfirmed(false);
setMessage(
submit
? 'Submissão privada registada. A oferta não está publicada.'
: 'Oferta guardada. Pode retomá-la mais tarde.',
);
if (!saved) router.visit(`${base}/${result.record.id}`);
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Não foi possível concluir. Os campos preenchidos foram preservados.',
);
if (error instanceof ProfileValidationError)
setErrors(error.fields);
} finally {
setBusy(false);
}
}
return (
<main className="mx-auto w-full max-w-4xl space-y-6 p-4 md:p-8">
<Head title="Preparar oferta" />
<OfferNavigation canCreate={contexts.length > 0} />
<header>
<h1 className="text-2xl font-semibold">Preparar oferta</h1>
<p className="text-muted-foreground mt-2">
{saved?.state === 'submitted'
? 'Submissão privada registada. A oferta permanece sem publicação.'
: 'Complete os dados ao seu ritmo. A gravação e a submissão mantêm a oferta privada.'}
</p>
{saved && <p className="mt-2 text-sm">{saved.reference}</p>}
</header>
{saved && (
<OfferEditorialReview
record={saved}
onChanged={(next) => {
setSaved(next);
setDirty(false);
setConfirmed(false);
}}
/>
)}
<div role="status" aria-live="polite">
{message}
</div>
{saved && (
<OfferOperations
id={saved.id}
onChanged={() => {
void profileJson(
`/minhas-oportunidades/ofertas/${saved.id}`,
{},
'GET',
)
.then((result) => {
setSaved(result.record);
setConfirmed(false);
})
.catch((error) => setMessage(error.message));
}}
/>
)}
{Object.keys(errors).length > 0 && (
<div
role="alert"
className="border-destructive rounded-md border p-3"
>
<p>Confira os seguintes campos:</p>
<ul>
{Object.entries(errors).map(([key, value]) => (
<li key={key}>{value}</li>
))}
</ul>
</div>
)}
<form
onSubmit={(event) => {
event.preventDefault();
void execute(false);
}}
className="space-y-6"
>
<fieldset
disabled={busy || readOnly}
className="space-y-4 rounded-lg border p-4 md:p-6"
>
<legend className="px-2 font-semibold">
1. Identificação
</legend>
<label className="block">
Categoria e promotor
<select
className={control}
value={`${context.organization_id ?? ''}|${context.type}`}
onChange={(event) => {
const next = contexts.find(
(item) =>
`${item.organization_id ?? ''}|${item.type}` ===
event.target.value,
);
if (next) {
setContext(next);
setDirty(true);
setConfirmed(false);
if (
next.organization_id !==
context.organization_id
)
updateOffer({
...offer,
contact_id: null,
});
}
}}
>
<option
value={`${context.organization_id ?? ''}|${context.type}`}
>
{modalityLabels[context.type]}
{context.organization_name
? ` — ${context.organization_name}`
: ''}
</option>
{contexts
.filter(
(item) =>
(!saved ||
item.organization_id ===
saved.organization_id) &&
!(
item.type === context.type &&
item.organization_id ===
context.organization_id
),
)
.map((item) => (
<option
key={`${item.organization_id}|${item.type}`}
value={`${item.organization_id ?? ''}|${item.type}`}
>
{modalityLabels[item.type]}
{item.organization_name
? ` — ${item.organization_name}`
: ''}
</option>
))}
</select>
</label>
<label className="block">
Título
<input
className={control}
maxLength={150}
value={title}
onChange={(event) => {
setTitle(event.target.value);
setDirty(true);
setConfirmed(false);
}}
/>
</label>
<label className="block">
Descrição
<textarea
className={control}
rows={5}
maxLength={50000}
value={description}
onChange={(event) => {
setDescription(event.target.value);
setDirty(true);
setConfirmed(false);
}}
/>
</label>
</fieldset>
<fieldset
disabled={busy || readOnly}
className="space-y-4 rounded-lg border p-4 md:p-6"
>
<legend className="px-2 font-semibold">
2. Preço e localização
</legend>
<label className="block">
Preço
<select
className={control}
value={offer.price_mode ?? ''}
onChange={(event) =>
updateOffer({
...offer,
price_mode: event.target.value || null,
amount: null,
currency:
event.target.value === 'amount'
? 'MZN'
: null,
})
}
>
<option value="">Completar mais tarde</option>
<option value="amount">Indicar montante</option>
<option value="negotiable">A negociar</option>
</select>
</label>
{offer.price_mode === 'amount' && (
<div className="grid gap-4 sm:grid-cols-2">
<label>
Montante
<input
className={control}
inputMode="decimal"
placeholder="Ex.: 125000.00"
maxLength={19}
value={offer.amount ?? ''}
onChange={(event) =>
updateOffer({
...offer,
amount: event.target.value,
})
}
/>
</label>
<label>
Moeda
<select
className={control}
value={offer.currency ?? ''}
onChange={(event) =>
updateOffer({
...offer,
currency: event.target.value,
})
}
>
<option value="">Seleccione a moeda</option>
<option>MZN</option>
<option>USD</option>
</select>
</label>
</div>
)}
<TerritoryFields
initial={record?.territory ?? emptyPath}
endpoint="/minhas-oportunidades/territory-options"
disabled={busy || readOnly}
onChange={(path) => {
setTerritory(path);
setDirty(true);
setConfirmed(false);
}}
/>
</fieldset>
<fieldset
disabled={busy || readOnly}
className="space-y-4 rounded-lg border p-4 md:p-6"
>
<legend className="px-2 font-semibold">
3. Contacto privado
</legend>
<label className="block">
Contacto do promotor
<select
className={control}
value={offer.contact_id ?? ''}
onChange={(event) =>
updateOffer({
...offer,
contact_id: event.target.value || null,
})
}
>
<option value="">
Seleccione um contacto autorizado
</option>
{contactChoices.map((contact) => (
<option key={contact.id} value={contact.id}>
{contact.full_name}
</option>
))}
</select>
</label>
<p className="text-muted-foreground text-sm">
O contacto não é divulgado publicamente.
</p>
</fieldset>
{!readOnly && (
<button className={button} disabled={busy} type="submit">
{busy ? 'A guardar…' : 'Guardar oferta'}
</button>
)}
</form>
<OfferParticularsFields
value={offer}
category={context.type}
sectors={sectors}
propertyTypes={propertyTypes}
disabled={busy || readOnly}
onChange={(next) => updateOffer({ ...offer, ...next })}
/>
{saved && (
<OfferPrivateResources
id={saved.id}
etag={saved.etag}
editable={saved.canEdit}
property={context.type === 'real_estate'}
disabled={dirty || busy}
onChanged={(etag) => {
setSaved((previous) =>
previous ? { ...previous, etag } : previous,
);
setConfirmed(false);
}}
/>
)}
{saved && context.type === 'business_sale' && (
<OfferConfidentialAccess
key={`${saved.id}-${saved.state}`}
id={saved.id}
onChanged={(etag) => {
setSaved((previous) =>
previous ? { ...previous, etag } : previous,
);
setConfirmed(false);
}}
/>
)}
{saved?.canEdit && (
<details className="rounded-lg border p-4">
<summary className="cursor-pointer font-medium">
Acrescentar um contacto
</summary>
<p className="my-3 text-sm">
Utilize o cadastro existente. A gravação do contacto
exige MFA recente.
</p>
<OpportunityContactForm
id={saved.id}
etag={saved.etag}
organization={saved.organization_id}
investors={contactInvestors}
disabled={busy}
onSaved={(contact) => {
setContactChoices((previous) => [
...previous.filter(
(item) => item.id !== contact.id,
),
contact,
]);
updateOffer({ ...offer, contact_id: contact.id });
}}
/>
</details>
)}
{!saved && (
<p className="text-muted-foreground text-sm">
Guarde primeiro para poder acrescentar um contacto ao
cadastro existente.
</p>
)}
{saved?.canEdit && (
<section className="space-y-3 rounded-lg border p-4 md:p-6">
<h2 className="font-semibold">4. Conferir e submeter</h2>
<p className="text-sm">
A submissão regista a versão para validação futura. Não
publica a oferta.
</p>
{dirty && (
<p className="text-sm">
Guarde as alterações antes de submeter.
</p>
)}
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={confirmed}
disabled={busy || dirty}
onChange={(event) =>
setConfirmed(event.target.checked)
}
/>
Conferi os dados guardados e confirmo a submissão
privada.
</label>
<button
type="button"
className={button}
disabled={busy || dirty || !confirmed}
onClick={() => void execute(true)}
>
Submeter em privado
</button>
</section>
)}
</main>
);
}