Files
Investidor-V2/resources/js/components/proactive-supply.tsx
T

414 lines
17 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Link } from '@inertiajs/react';
import { button, control, Field } from '@/pages/management/shared';
import { createOperationKey } from '@/lib/operation-key';
import type { ProactiveAnalysis } from './proactive-investment';
type Observation = {
id: string;
name: string;
province: string;
district: string;
territory_id: string;
cae: string;
activity: string;
sector: string;
source_date: string;
sheet: string;
row_number: number;
source_hash: string;
source_name: string;
source_period: string;
identity_conflict: boolean;
};
type Result = {
installed: boolean;
rows: Observation[];
total: number | null;
duplicate_sightings?: number;
page?: number;
per_page?: number;
sectors?: string[];
territory_coverage?: { unmapped: number; province_only: number };
limitations: string;
status: string;
};
export function ProactiveSupplyBrowser({
value,
onChange,
territoryId,
recordId,
}: {
value: ProactiveAnalysis;
onChange: (v: ProactiveAnalysis) => void;
territoryId?: string;
recordId?: number;
}) {
const [activity, setActivity] = useState('');
const [sector, setSector] = useState('');
const [loaded, setLoaded] = useState<{ key: string; data: Result } | null>(
null,
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [query, setQuery] = useState<{
territory: string;
activity: string;
sector: string;
page: number;
} | null>(null);
const requestKey =
query?.territory === territoryId
? JSON.stringify([recordId, query])
: '';
const result =
requestKey && loaded?.key === requestKey ? loaded.data : null;
useEffect(() => {
setLoaded(null);
setQuery(null);
setSector('');
setError('');
setBusy(false);
}, [territoryId]);
useEffect(() => {
if (!query) return;
const controller = new AbortController();
setBusy(true);
setError('');
const p = new URLSearchParams({
territory_id: query.territory,
activity: query.activity,
sector: query.sector,
page: String(query.page),
});
if (recordId) p.set('record_id', String(recordId));
fetch('/minhas-oportunidades/oferta-interna?' + p, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
signal: controller.signal,
})
.then(async (r) => {
if (!r.ok)
throw new Error(
r.status === 403
? 'Consulta interna fora do âmbito de acesso desta sessão.'
: 'Não foi possível consultar a base interna. A oferta permanece por determinar.',
);
return r.json() as Promise<Result>;
})
.then((data) => {
if (!controller.signal.aborted)
setLoaded({ key: JSON.stringify([recordId, query]), data });
})
.catch((e) => {
if (!controller.signal.aborted && e.name !== 'AbortError') {
setLoaded(null);
setError(e.message);
}
})
.finally(() => {
if (!controller.signal.aborted) setBusy(false);
});
return () => controller.abort();
}, [query, recordId]);
const selected = value.supply_comparisons ?? [];
const compare = (row: Observation) => {
if (selected.some((c) => c.observation_id === row.id)) return;
const proof = value.evidence.find((e) => e.observation_id === row.id);
onChange({
...value,
supply_comparisons: [
...selected,
{
observation_id: row.id,
relationship: 'uncertain',
rationale: '',
limitations:
'Actividade actual, capacidade, procura e disponibilidade para parceria por confirmar.',
},
],
evidence: proof
? value.evidence
: [
...value.evidence,
{
id: createOperationKey(),
observation_id: row.id,
category: 'supply',
purpose: 'local',
access: 'internal',
document_id: '',
statement: `Registo administrativo: ${row.name}; CAE ${row.cae}; ${row.activity || 'actividade por classificar'}; ${row.district}, ${row.province}. Não confirma actividade actual.`,
interpretation: '',
source: `${row.source_name}; SHA256 ${row.source_hash}`,
locator: `${row.sheet}!linha ${row.row_number}`,
period: `${row.source_date}; ${row.source_period}`,
scope: `${row.district}, ${row.province}`,
coverage:
result?.limitations ??
'Cobertura incompleta; situação actual por confirmar.',
verified_on: new Date().toISOString().slice(0, 10),
},
],
});
};
return (
<fieldset className="space-y-3 rounded border p-3">
<legend>Consultar oferta existente — base interna</legend>
<Link
className="underline"
href={
'/minhas-oportunidades/oferta-interna/revisao' +
(recordId ? `?record_id=${recordId}` : '')
}
>
Conferir origens, repetições e correspondências por resolver
</Link>
<p>
Consulta a localização escolhida nesta ficha e os seus
territórios subordinados. Os filtros por sector e actividade CAE
não limitam a análise ao sector da ideia: podem existir
concorrentes, complementos e parceiros noutros sectores. Nenhuma
empresa é publicada.
</p>
<Field label="Sector da oferta (classificação CAE)">
<select
className={control}
aria-label="Sector da oferta (classificação CAE)"
value={sector}
onChange={(e) => setSector(e.target.value)}
>
<option value="">Todos os sectores registados</option>
{result?.sectors?.map((s) => (
<option key={s}>{s}</option>
))}
</select>
</Field>
<Field label="Actividade ou código CAE da oferta">
<input
className={control}
aria-label="Actividade ou código CAE da oferta"
value={activity}
onChange={(e) => setActivity(e.target.value)}
/>
</Field>
<button
type="button"
className={button}
disabled={!territoryId || busy}
onClick={() =>
setQuery({
territory: territoryId!,
activity,
sector,
page: 1,
})
}
>
{busy ? 'A consultar…' : 'Consultar oferta da localização'}
</button>
{!territoryId && (
<p>
Escolha a localização para consultar. Sem localização, a
oferta permanece por determinar.
</p>
)}
{error && <p role="alert">{error}</p>}
{result && (
<>
<p role="status">{result.status}</p>
<p>{result.limitations}</p>
{result.territory_coverage && (
<p>
Cobertura territorial da base nacional:{' '}
{result.territory_coverage.unmapped} observações sem
correspondência territorial única;{' '}
{result.territory_coverage.province_only} apenas com
província. Estas últimas não comprovam oferta num
distrito específico.
</p>
)}
{result.total !== null && (
<p>
{result.total} observações distintas;{' '}
{result.duplicate_sightings ?? 0} repetições dos
mesmos campos administrativos conservadas na origem.
Não é uma contagem de empresas únicas.
</p>
)}
{result.rows.map((row) => (
<article key={row.id} className="rounded border p-3">
<h3 className="font-semibold">{row.name}</h3>
<p>
{row.district}, {row.province} ·{' '}
{row.sector || 'Sector por classificar'} · CAE{' '}
{row.cae || 'não indicado'} ·{' '}
{row.activity || 'Actividade por classificar'}
</p>
<p>
Fonte: {row.source_name} · {row.sheet}, linha{' '}
{row.row_number} · {row.source_date} ·{' '}
{row.source_period}
</p>
<details>
<summary>Rastreabilidade do exemplar</summary>
<p>SHA256: {row.source_hash}</p>
<p>Observação: {row.id}</p>
</details>
{row.identity_conflict && (
<p>
Identificador partilhado por denominações
divergentes; identidade por conferir. Os
registos não foram fundidos.
</p>
)}
<button
type="button"
className={button}
disabled={
busy ||
selected.some(
(c) => c.observation_id === row.id,
) ||
selected.length >= 40 ||
value.evidence.length >= 60
}
onClick={() => compare(row)}
>
Comparar com a ideia
</button>
</article>
))}
{query && (
<div className="flex gap-3">
<button
type="button"
disabled={busy || query.page <= 1}
onClick={() =>
setQuery({ ...query, page: query.page - 1 })
}
>
Página anterior
</button>
<span>Página {result.page ?? 1}</span>
<button
type="button"
disabled={
busy ||
query.page * 25 >= (result.total ?? 0)
}
onClick={() =>
setQuery({ ...query, page: query.page + 1 })
}
>
Página seguinte
</button>
</div>
)}
</>
)}
{selected.map((c, i) => {
const proof = value.evidence.find(
(e) => e.observation_id === c.observation_id,
);
const patch = (p: Partial<typeof c>) =>
onChange({
...value,
supply_comparisons: selected.map((x) =>
x.observation_id === c.observation_id
? { ...x, ...p }
: x,
),
});
return (
<fieldset
className="space-y-2 rounded border p-3"
key={c.observation_id}
>
<legend>Comparação {i + 1}</legend>
<p>
{proof?.statement ??
'Evidência retirada: volte a associar a observação antes de guardar.'}
</p>
<p>
{proof?.source} · {proof?.locator}
</p>
<p>
Ideia em preparação:{' '}
{value.idea.solution ||
'Descreva a ideia no campo existente abaixo.'}
</p>
<Field label={`Relação com a ideia ${i + 1}`}>
<select
className={control}
value={c.relationship}
onChange={(e) =>
patch({
relationship: e.target
.value as typeof c.relationship,
})
}
>
<option value="uncertain">
Relação por determinar
</option>
<option value="competition">
Possível concorrência
</option>
<option value="complementarity">
Possível complementaridade
</option>
<option value="partner">
Possível parceiro — sem compromisso
</option>
</select>
</Field>
<Field label={`Fundamento da comparação ${i + 1}`}>
<textarea
className={control}
value={c.rationale}
onChange={(e) =>
patch({ rationale: e.target.value })
}
/>
</Field>
<Field label={`Incertezas da comparação ${i + 1}`}>
<textarea
className={control}
value={c.limitations}
onChange={(e) =>
patch({ limitations: e.target.value })
}
/>
</Field>
<button
type="button"
onClick={() =>
onChange({
...value,
supply_comparisons: selected.filter(
(x) =>
x.observation_id !==
c.observation_id,
),
})
}
>
Retirar comparação {i + 1}
</button>
</fieldset>
);
})}
{!!selected.length && (
<p>
Use estas comparações para fundamentar a necessidade abaixo
e seleccione as evidências correspondentes. A fonte
administrativa não confirma procura nem insuficiência de
oferta. Guarde no rascunho existente; a continuidade para o
dossier conserva o percurso já entregue.
</p>
)}
</fieldset>
);
}