548 lines
23 KiB
TypeScript
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>
|
|
);
|
|
}
|