Skip to content

Commit

Permalink
chore: up
Browse files Browse the repository at this point in the history
  • Loading branch information
rap2hpoutre committed Jan 16, 2024
1 parent 32e0a94 commit c99d0d4
Show file tree
Hide file tree
Showing 2 changed files with 221 additions and 64 deletions.
283 changes: 220 additions & 63 deletions dashboard/src/scenes/data-import-export/ImportConfig.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { useRef, useState } from 'react';
import { MutableRefObject, useRef, useState } from 'react';
import { utils, read, writeFile, WorkBook } from 'xlsx';
import ButtonCustom from '../../components/ButtonCustom';
import { useRecoilState, useRecoilValue } from 'recoil';
import { customFieldsPersonsSelector } from '../../recoil/persons';
import { typeOptions } from '../../utils';
import { customFieldsMedicalFileSelector } from '../../recoil/medicalFiles';
import { organisationState } from '../../recoil/auth';
import { territoriesState } from '../../recoil/territory';
import { customFieldsObsSelector } from '../../recoil/territoryObservations';
import { servicesSelector } from '../../recoil/reports';
import { actionsCategoriesSelector } from '../../recoil/actions';

const typeOptions = [
const typeOptionsLabels = [
'Texte',
'Zone de texte multi-lignes',
'Nombre',
Expand All @@ -13,7 +22,15 @@ const typeOptions = [
'Choix multiple dans une liste',
'Case à cocher',
] as const;
type TypeOption = typeof typeOptions[number];
type TypeOption = typeof typeOptionsLabels[number];

function isTypeOptionLabel(type: string): type is TypeOption {
return typeOptionsLabels.includes(type as any);
}

function requiresOptions(type: TypeOption): boolean {
return ['Choix dans une liste', 'Choix multiple dans une liste'].includes(type);
}

const sheetNames = [
'Infos social et médical',
Expand All @@ -26,49 +43,31 @@ const sheetNames = [
] as const;
type SheetName = typeof sheetNames[number];

const config: Record<
SheetName,
{
cols: string[];
examples: ([string, TypeOption, string[]] | [string, string, TypeOption, string[]] | [string, string][] | [string])[];
}
> = {
'Infos social et médical': {
cols: ['Rubrique', 'Intitulé du champ', 'Type de champ', 'Choix'],
examples: [
['Informations sociales', 'Situation personnelle', 'Texte', ['Isolé.e', 'Couple avec enfant(s)']],
[
'Informations de santé',
'Couverture(s) médicale(s)',
'Choix multiple dans une liste',
['Aucune', 'Régime Général', 'PUMa', 'AME', 'CSS', 'Autre'],
],
],
},
'Dossier médical': {
cols: ['Intitulé du champ', 'Type de champ', 'Choix'],
examples: [['Numéro de sécurité sociale', 'Texte', []]],
},
Consultation: { cols: ['Consultation type pour', 'Intitulé du champ', 'Type de champ', 'Choix'], examples: [] },
'Liste des territoires': { cols: ['Liste des territoires ou structures visitées'], examples: [] },
'Observation de territoire': { cols: ['Intitulé du champ', 'Type de champ', 'Choix'], examples: [] },
'Liste des services': { cols: ['Liste des services', 'Groupe'], examples: [] },
"Catégories d'action": { cols: ["Liste des catégories d'action", "Groupe d'action"], examples: [] },
const workbookColumns: Record<SheetName, string[]> = {
'Infos social et médical': ['Rubrique', 'Intitulé du champ', 'Type de champ', 'Choix'],
'Dossier médical': ['Intitulé du champ', 'Type de champ', 'Choix'],
Consultation: ['Consultation type pour', 'Intitulé du champ', 'Type de champ', 'Choix'],
'Liste des territoires': ['Liste des territoires ou structures visitées'],
'Observation de territoire': ['Intitulé du champ', 'Type de champ', 'Choix'],
'Liste des services': ['Liste des services', 'Groupe'],
"Catégories d'action": ["Liste des catégories d'action", "Groupe d'action"],
};

function isTypeOption(type: string): type is TypeOption {
return typeOptions.includes(type as any);
}

type WorkbookData = Record<
SheetName,
{
data: any[];
data: Record<string, string | string[]>[];
globalErrors: string[];
errors: { line: number; col: number; message: string }[];
}
>;

function trimAllValues<R extends Record<string, string | string[]>>(obj: R): R {
if (typeof obj !== 'object') return obj;
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, typeof v === 'string' ? v.trim() : v.map((s) => s.trim())])) as R;
}

// Parse le fichier Excel et retourne un objet contenant les données et les erreurs
function processConfigWorkbook(workbook: WorkBook): WorkbookData {
const data: WorkbookData = sheetNames.reduce((acc, sheetName) => {
return { ...acc, [sheetName]: { data: [], globalErrors: [], errors: [] } };
Expand All @@ -80,7 +79,7 @@ function processConfigWorkbook(workbook: WorkBook): WorkbookData {
}
const sheet = workbook.Sheets[sheetName];
const rows = utils.sheet_to_json<string[]>(sheet, { header: 1 });
for (const col of config[sheetName].cols) {
for (const col of workbookColumns[sheetName]) {
if (!rows[0].includes(col)) data[sheetName].globalErrors.push(`La colonne ${col} est manquante`);
}
if (data[sheetName].globalErrors.length > 0) continue;
Expand All @@ -89,23 +88,85 @@ function processConfigWorkbook(workbook: WorkBook): WorkbookData {
for (const key in rowsWithoutHeader) {
const row = rowsWithoutHeader[key];
if (sheetName === 'Infos social et médical') {
console.log('row', row);
const [rubrique, intitule, type, choix] = row;
if (!isTypeOption(type)) {
data[sheetName].errors.push({ line: parseInt(key), col: 2, message: `Le type ${type} n'existe pas` });
}
data[sheetName].data.push({ rubrique, intitule, type, choix: choix ? choix.split(',').map((s) => s.trim()) : [] });
if (!rubrique) data[sheetName].errors.push({ line: parseInt(key), col: 0, message: `La rubrique est manquante` });
if (!intitule) data[sheetName].errors.push({ line: parseInt(key), col: 1, message: `L'intitulé du champ est manquant` });
if (!type) data[sheetName].errors.push({ line: parseInt(key), col: 2, message: `Le type de champ est manquant` });
if (requiresOptions(type as TypeOption) && !choix)
data[sheetName].errors.push({ line: parseInt(key), col: 3, message: `Les choix sont manquants` });
if (!isTypeOptionLabel(type)) data[sheetName].errors.push({ line: parseInt(key), col: 2, message: `Le type ${type} n'existe pas` });
data[sheetName].data.push(trimAllValues({ rubrique, intitule, type, choix: choix?.split(',') || [] }));
}

if (sheetName === 'Dossier médical') {
const [intitule, type, choix] = row;
if (!intitule) data[sheetName].errors.push({ line: parseInt(key), col: 0, message: `L'intitulé du champ est manquant` });
if (!type) data[sheetName].errors.push({ line: parseInt(key), col: 1, message: `Le type de champ est manquant` });
if (requiresOptions(type as TypeOption) && !choix)
data[sheetName].errors.push({ line: parseInt(key), col: 2, message: `Les choix sont manquants` });
if (!isTypeOptionLabel(type)) data[sheetName].errors.push({ line: parseInt(key), col: 1, message: `Le type ${type} n'existe pas` });
data[sheetName].data.push(trimAllValues({ intitule, type, choix: choix?.split(',') || [] }));
}

if (sheetName === 'Consultation') {
const [rubrique, intitule, type, choix] = row;
if (!rubrique) data[sheetName].errors.push({ line: parseInt(key), col: 0, message: `La rubrique est manquante` });
if (!intitule) data[sheetName].errors.push({ line: parseInt(key), col: 1, message: `L'intitulé du champ est manquant` });
if (!type) data[sheetName].errors.push({ line: parseInt(key), col: 2, message: `Le type de champ est manquant` });
if (requiresOptions(type as TypeOption) && !choix)
data[sheetName].errors.push({ line: parseInt(key), col: 3, message: `Les choix sont manquants` });
if (!isTypeOptionLabel(type)) data[sheetName].errors.push({ line: parseInt(key), col: 2, message: `Le type ${type} n'existe pas` });
data[sheetName].data.push(trimAllValues({ rubrique, intitule, type, choix: choix?.split(',') || [] }));
}

if (sheetName === 'Liste des territoires') {
const [territoire] = row;
if (!territoire) data[sheetName].errors.push({ line: parseInt(key), col: 0, message: `Le nom du territoire est manquant` });
data[sheetName].data.push(trimAllValues({ territoire }));
}

if (sheetName === 'Observation de territoire') {
const [intitule, type, choix] = row;
if (!intitule) data[sheetName].errors.push({ line: parseInt(key), col: 0, message: `L'intitulé du champ est manquant` });
if (!type) data[sheetName].errors.push({ line: parseInt(key), col: 1, message: `Le type de champ est manquant` });
if (requiresOptions(type as TypeOption) && !choix)
data[sheetName].errors.push({ line: parseInt(key), col: 2, message: `Les choix sont manquants` });
if (!isTypeOptionLabel(type)) data[sheetName].errors.push({ line: parseInt(key), col: 1, message: `Le type ${type} n'existe pas` });
data[sheetName].data.push(trimAllValues({ intitule, type, choix: choix?.split(',') || [] }));
}

if (sheetName === 'Liste des services') {
const [service, groupe] = row;
if (!service) data[sheetName].errors.push({ line: parseInt(key), col: 0, message: `Le nom du service est manquant` });
if (!groupe) data[sheetName].errors.push({ line: parseInt(key), col: 1, message: `Le nom du groupe est manquant` });
data[sheetName].data.push(trimAllValues({ service, groupe }));
}

if (sheetName === "Catégories d'action") {
const [categorie, groupe] = row;
if (!categorie) data[sheetName].errors.push({ line: parseInt(key), col: 0, message: `Le nom de la catégorie est manquant` });
if (!groupe) data[sheetName].errors.push({ line: parseInt(key), col: 1, message: `Le nom du groupe est manquant` });
data[sheetName].data.push(trimAllValues({ categorie, groupe }));
}
}
}
return data;
}

const ExcelParser = () => {
const fileDialogRef = useRef(null);
const ExcelParser = ({ scrollContainer }: { scrollContainer: MutableRefObject<HTMLDivElement> }) => {
const fileDialogRef = useRef<HTMLInputElement>(null);
const [workbookData, setWorkbookData] = useState<WorkbookData | null>(null);
const [reloadKey, setReloadKey] = useState(0); // because input type 'file' doesn't trigger 'onChange' for uploading twice the same file

const [organisation] = useRecoilState(organisationState);
const customFieldsPersons = useRecoilValue(customFieldsPersonsSelector);
const customFieldsMedicalFile = useRecoilValue(customFieldsMedicalFileSelector);
const customFieldsObs = useRecoilValue(customFieldsObsSelector);
const territories = useRecoilValue(territoriesState);
const groupedServices = useRecoilValue(servicesSelector);
const actionsGroupedCategories = useRecoilValue(actionsCategoriesSelector);
const consultationFields = organisation!.consultations;

const handleFileUpload = (e: any) => {
const file = e.target.files[0];
const reader = new FileReader();
Expand All @@ -123,6 +184,7 @@ const ExcelParser = () => {

const data = processConfigWorkbook(workbook);
setWorkbookData(data);
scrollContainer.current.scrollTo({ top: 0 });
};

reader.readAsBinaryString(file);
Expand All @@ -133,37 +195,129 @@ const ExcelParser = () => {
{!workbookData ? (
<>
<p>
Vous pouvez importer une configuration depuis un fichier Excel en remplissant le modèle ci-dessous. Attention ! Cette opération va écraser
votre configuration actuelle.
Vous pouvez importer une configuration complète depuis un fichier Excel en téléchargeant la configuration actuelle et en la modifiant. Il
est recommandé de faire cette opération en début de paramétrage sur une organisation vide. En cliquant sur importer, vous visualizerez les
données qui seront importées, et les erreurs qui ont été trouvées. Cela permet de contrôler que tout est correct avant de valider. Si vous
n'avez pas d'erreur, vous pouvez ensuite cliquer sur le bouton "Valider" à l'étape suivante. Les champs attendus sont les suivants:
</p>
<table className="table-sm table" style={{ fontSize: '14px', marginTop: '2rem' }}>
<thead>
<tr>
<th>Feuille</th>
<th>Colonne</th>
<th className="tw-max-w-sm">Valeur</th>
</tr>
</thead>
<tbody>
{Object.entries(workbookColumns).map(([sheetName, columns]) => {
return columns.map((col, i) => (
<tr key={i}>
<td>{sheetName}</td>
<td>{col}</td>
<td>{col === 'Choix' ? <code className="tw-whitespace-pre">{typeOptionsLabels.join('\n')}</code> : 'Texte'}</td>
</tr>
));
})}
</tbody>
</table>
<div className="tw-mb-10 tw-flex tw-justify-end tw-gap-4">
<ButtonCustom
onClick={() => {
// Création d'un nouveau classeur
const workbook = utils.book_new();

// Création des feuilles et ajout des données
Object.entries(config).forEach(([sheetName, { cols, examples }]) => {
const worksheet = utils.aoa_to_sheet([
cols,
...(examples || []).map((values) => values.map((value) => (Array.isArray(value) ? value.join(', ') : value))),
]);
utils.book_append_sheet(workbook, worksheet, sheetName);
});
// Création de chaque onglet
utils.book_append_sheet(
workbook,
utils.aoa_to_sheet([
['Rubrique', 'Intitulé du champ', 'Type de champ', 'Choix'],
...customFieldsPersons.reduce((acc, curr) => {
return [
...acc,
...curr.fields.map((e) => [
curr.name,
e.label,
typeOptions.find((t) => t.value === e.type)!.label,
(e.options || []).join(','),
]),
];
}, [] as string[][]),
]),
'Infos social et médical'
);

utils.book_append_sheet(
workbook,
utils.aoa_to_sheet([
['Intitulé du champ', 'Type de champ', 'Choix'],
...customFieldsMedicalFile.map((e) => [e.label, typeOptions.find((t) => t.value === e.type)!.label, (e.options || []).join(',')]),
]),
'Dossier médical'
);

utils.book_append_sheet(
workbook,
utils.aoa_to_sheet([
['Consultation type pour', 'Intitulé du champ', 'Type de champ', 'Choix'],
...consultationFields.reduce((acc, curr) => {
return [
...acc,
...curr.fields.map((e) => [
curr.name,
e.label,
typeOptions.find((t) => t.value === e.type)!.label,
(e.options || []).join(','),
]),
];
}, [] as string[][]),
]),
'Consultation'
);

utils.book_append_sheet(
workbook,
utils.aoa_to_sheet([['Liste des territoires ou structures visitées'], ...territories.map((e: { name: string }) => [e.name])]),
'Liste des territoires'
);

utils.book_append_sheet(
workbook,
utils.aoa_to_sheet([
['Intitulé du champ', 'Type de champ', 'Choix'],
...customFieldsObs.map((e) => [e.label, typeOptions.find((t) => t.value === e.type)!.label, (e.options || []).join(',')]),
]),
'Observation de territoire'
);

utils.book_append_sheet(
workbook,
utils.aoa_to_sheet([
['Liste des services', 'Groupe'],
...groupedServices.reduce((acc, curr) => {
return [...acc, ...curr.services.map((e: string) => [e, curr.groupTitle])];
}, [] as string[][]),
]),
'Liste des services'
);

utils.book_append_sheet(
workbook,
utils.aoa_to_sheet([
["Liste des catégories d'action", "Groupe d'action"],
...actionsGroupedCategories.reduce((acc, curr) => {
return [...acc, ...curr.categories.map((e: string) => [e, curr.groupTitle])];
}, [] as string[][]),
]),
"Catégories d'action"
);

// Écriture du fichier XLSX
writeFile(workbook, 'data.xlsx');
}}
color="primary"
title="Télécharger le modèle"
padding="12px 24px"
/>
<ButtonCustom
onClick={() => (fileDialogRef.current as any).click()}
color="primary"
title="Importer un fichier .xlsx"
title="Télécharger la configuration actuelle"
padding="12px 24px"
/>
<ButtonCustom onClick={() => fileDialogRef.current?.click()} color="primary" title="Importer un fichier .xlsx" padding="12px 24px" />
<input
ref={fileDialogRef}
key={reloadKey}
Expand Down Expand Up @@ -206,7 +360,7 @@ const ExcelParser = () => {
<table className="tw-w-full">
<thead>
<tr className="tw-border-b">
{['#', 'Statut', ...config[sheetName as SheetName].cols].map((col) => (
{['#', 'Statut', ...workbookColumns[sheetName as SheetName]].map((col) => (
<th className="tw-bg-slate-50 tw-py-2 tw-px-4 tw-text-sm tw-font-normal" key={col}>
{col}
</th>
Expand Down Expand Up @@ -256,6 +410,9 @@ const ExcelParser = () => {
)}
</div>
))}
<div className="tw-mt-8 tw-flex tw-justify-end tw-gap-4">
<ButtonCustom onClick={() => alert('todo')} color="primary" title="Valider l'import" padding="12px 24px" />
</div>
</div>
)}
</div>
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/scenes/organisation/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ const View = () => {
<>
<TabTitle>Importer une configuration</TabTitle>
<div>
<ImportConfig />
<ImportConfig scrollContainer={scrollContainer} />
</div>
</>
);
Expand Down

0 comments on commit c99d0d4

Please sign in to comment.