Files
Investidor-V2/resources/js/components/opportunity-alert-preferences.tsx
T
2026-11-02 20:31:15 +02:00

430 lines
20 KiB
TypeScript

import { useForm } from '@inertiajs/react';
import { modalityLabels } from '@/components/opportunity-search-filters';
import { button, card, input } from '@/pages/investment/shared';
export type AlertPreferences = {
version: number;
enabled: boolean;
frequency: string;
channel: string;
next_run_at: string | null;
criteria_available?: boolean;
sources?: string[];
criteria: {
sector?: string;
province_id?: string;
category?: string;
tender_regime?: string;
funding_certainty?: string;
funding_state?: string;
};
};
export type AlertOptions = {
frequencies: Record<string, string>;
sectors: { slug: string; name: string }[];
provinces: { id: string; name: string }[];
categories: string[];
tendersAvailable?: boolean;
fundingAvailable?: boolean;
tenderRegimes?: Record<string, string>;
tenderActs?: Record<string, string>;
};
export default function OpportunityAlertPreferences({
preferences,
options,
}: {
preferences: AlertPreferences;
options: AlertOptions;
}) {
const form = useForm({
version: preferences.version,
enabled: true,
frequency: preferences.frequency,
channel: 'in_app',
sources: preferences.sources ?? ['opportunities'],
criteria: {
sector: preferences.criteria.sector ?? '',
province_id: preferences.criteria.province_id ?? '',
category: preferences.criteria.category ?? '',
tender_regime: preferences.criteria.tender_regime ?? '',
funding_certainty:
preferences.criteria.funding_certainty ?? 'documented',
funding_state: preferences.criteria.funding_state ?? '',
},
consent: false,
});
const send = (enabled: boolean) => {
form.transform((data) => ({
...data,
enabled,
consent: enabled && data.consent,
}));
form.put('/investidor/alert-preferences', { preserveScroll: true });
};
return (
<section
className={card + ' space-y-3'}
aria-label="Preferências de alertas"
>
<h2 className="text-xl font-bold">
Novidades dos catálogos públicos
</h2>
<p role="status">
{preferences.enabled
? 'Acompanhamento activo.'
: 'Acompanhamento desactivado.'}
</p>
{preferences.enabled &&
preferences.criteria_available === false && (
<p role="alert">
Uma selecção anterior deixou de estar disponível no
catálogo. O acompanhamento das selecções indisponíveis
fica suspenso; os restantes catálogos seleccionados
continuam a ser verificados. Pode escolher outra opção
ou cancelar os alertas a qualquer momento.
</p>
)}
<p>
Escolha os catálogos, critérios e frequência. As novas
disponibilizações compatíveis aparecem nesta área. Não são
enviados emails ou SMS. O acompanhamento de concursos considera
anúncios e actos publicados no catálogo do portal. Fundos e
apoios consideram novas versões do catálogo do portal; não há
recolha automática de fontes externas.
</p>
<p>
Ao activar, reactivar ou alterar os critérios ou catálogos, os
registos já disponíveis estabelecem o ponto de partida e não
geram alertas retroactivos. Uma oportunidade ou versão de acto
não gera alertas repetidos.
</p>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
send(true);
}}
>
<fieldset disabled={form.processing} className="space-y-3">
<legend className="font-semibold">
O que pretende acompanhar
</legend>
<fieldset className="space-y-2">
<legend className="font-semibold">Catálogos</legend>
{(['opportunities', 'tenders', 'funding'] as const).map(
(source) => (
<label
key={source}
className="flex items-start gap-2"
>
<input
name={`source-${source}`}
type="checkbox"
checked={form.data.sources.includes(
source,
)}
disabled={
((source === 'tenders' &&
!options.tendersAvailable) ||
(source === 'funding' &&
!options.fundingAvailable)) &&
!form.data.sources.includes(source)
}
onChange={(event) => {
form.setData(
'sources',
event.target.checked
? [
...form.data.sources,
source,
]
: form.data.sources.filter(
(value) =>
value !== source,
),
);
form.setData('consent', false);
}}
/>
{source === 'opportunities'
? 'Oportunidades de investimento'
: source === 'tenders'
? 'Concursos e actos publicados'
: 'Fundos, programas e incentivos publicados'}
</label>
),
)}
{!options.tendersAvailable && (
<p className="text-sm">
O catálogo de concursos não está disponível
neste momento.
</p>
)}
{!options.fundingAvailable && (
<p className="text-sm">
O catálogo de fundos não está disponível neste
momento.
</p>
)}
</fieldset>
<label className="block">
Sector
<select
className={input}
value={form.data.criteria.sector}
onChange={(event) =>
form.setData('criteria', {
...form.data.criteria,
sector: event.target.value,
})
}
>
<option value="">Todos os sectores</option>
{form.data.criteria.sector &&
!options.sectors.some(
(sector) =>
sector.slug ===
form.data.criteria.sector,
) && (
<option value={form.data.criteria.sector}>
Sector anterior indisponível
</option>
)}
{options.sectors.map((sector) => (
<option key={sector.slug} value={sector.slug}>
{sector.name}
</option>
))}
</select>
</label>
<label className="block">
Província
<select
className={input}
value={form.data.criteria.province_id}
onChange={(event) =>
form.setData('criteria', {
...form.data.criteria,
province_id: event.target.value,
})
}
>
<option value="">Todo o território</option>
{form.data.criteria.province_id &&
!options.provinces.some(
(province) =>
province.id ===
form.data.criteria.province_id,
) && (
<option
value={form.data.criteria.province_id}
>
Província anterior indisponível
</option>
)}
{options.provinces.map((province) => (
<option key={province.id} value={province.id}>
{province.name}
</option>
))}
</select>
</label>
<p className="text-sm">
A selecção provincial usa o mesmo âmbito territorial do
catálogo, incluindo locais dos níveis inferiores.
</p>
{form.data.sources.includes('opportunities') && (
<label className="block">
Modalidade
<select
className={input}
value={form.data.criteria.category}
onChange={(event) =>
form.setData('criteria', {
...form.data.criteria,
category: event.target.value,
})
}
>
<option value="">Todas as modalidades</option>
{form.data.criteria.category &&
!options.categories.includes(
form.data.criteria.category,
) && (
<option
value={form.data.criteria.category}
>
Modalidade anterior indisponível
</option>
)}
{options.categories.map((category) => (
<option key={category} value={category}>
{modalityLabels[category] ?? category}
</option>
))}
</select>
</label>
)}
{form.data.sources.includes('tenders') && (
<label className="block">
Regime do concurso
<select
className={input}
value={form.data.criteria.tender_regime}
onChange={(event) =>
form.setData('criteria', {
...form.data.criteria,
tender_regime: event.target.value,
})
}
>
<option value="">Todos os regimes</option>
{form.data.criteria.tender_regime &&
!Object.hasOwn(
options.tenderRegimes ?? {},
form.data.criteria.tender_regime,
) && (
<option
value={
form.data.criteria.tender_regime
}
>
Regime anterior indisponível
</option>
)}
{Object.entries(
options.tenderRegimes ?? {},
).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
)}
{form.data.sources.includes('funding') && (
<fieldset className="space-y-3">
<legend>Condições para fundos e apoios</legend>
<label className="block">
Âmbitos por confirmar
<select
className={input}
value={form.data.criteria.funding_certainty}
onChange={(event) =>
form.setData('criteria', {
...form.data.criteria,
funding_certainty:
event.target.value,
})
}
>
<option value="documented">
Só âmbito documentado para o sector e
província escolhidos
</option>
<option value="include_unknown">
Incluir e assinalar informação por
confirmar
</option>
</select>
</label>
<label className="block">
Estado de uma janela
<select
className={input}
value={form.data.criteria.funding_state}
onChange={(event) =>
form.setData('criteria', {
...form.data.criteria,
funding_state: event.target.value,
})
}
>
<option value="">Qualquer estado</option>
<option value="open">
Aberta segundo as datas documentadas
</option>
<option value="upcoming">
Abertura futura
</option>
<option value="closed">Encerrada</option>
<option value="unknown">
Por confirmar
</option>
</select>
</label>
<p className="text-sm">
Sector e província referem-se ao programa. Uma
chamada pode ter cobertura mais restrita. O
aviso acompanha novas versões publicadas; uma
mudança de data sem nova publicação não gera
outro aviso.
</p>
</fieldset>
)}
<label className="block">
Frequência
<select
className={input}
value={form.data.frequency}
onChange={(event) =>
form.setData('frequency', event.target.value)
}
>
{Object.entries(options.frequencies).map(
([key, label]) => (
<option key={key} value={key}>
{label}
</option>
),
)}
</select>
</label>
<p>
Canal: notificações no portal. «Na próxima verificação»
depende da execução periódica do acompanhamento.
</p>
<label className="flex items-start gap-2">
<input
type="checkbox"
name="alert-consent"
checked={form.data.consent}
onChange={(event) =>
form.setData('consent', event.target.checked)
}
/>
Quero receber estes alertas no portal e confirmo os
critérios escolhidos.
</label>
{Object.entries(form.errors).map(([key, error]) => (
<p key={key} role="alert">
{error}
</p>
))}
<div className="flex flex-wrap gap-3">
<button type="submit" className={button}>
{preferences.enabled
? 'Guardar preferências'
: 'Activar alertas'}
</button>
{preferences.enabled && (
<button
type="button"
className="underline"
onClick={() => send(false)}
>
Cancelar estes alertas
</button>
)}
</div>
</fieldset>
</form>
<p className="text-sm">
O cancelamento conserva as notificações anteriores e não altera
o acompanhamento das manifestações já apresentadas.
</p>
</section>
);
}