414 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|