diff --git a/server/controllers/phaseHistory.ts b/server/controllers/phaseHistory.ts new file mode 100644 index 000000000..75dd0aef0 --- /dev/null +++ b/server/controllers/phaseHistory.ts @@ -0,0 +1,67 @@ +import { PhaseHistory } from '../entities/phaseHistory'; +import { Village, VillagePhase } from '../entities/village'; +import { AppDataSource } from '../utils/data-source'; +import { Controller } from './controller'; + +const handlePhase1toPhase3Case = async (village: Village, phase: number) => { + if (village.activePhase === VillagePhase.DISCOVER && phase === VillagePhase.IMAGINE) { + const phaseHistory = new PhaseHistory(); + phaseHistory.village = village; + phaseHistory.phase = VillagePhase.EXCHANGE; + phaseHistory.endingOn = new Date(); + + const phaseHistoryRepository = AppDataSource.getRepository(PhaseHistory); + + await phaseHistoryRepository.save(phaseHistory); + + const endPhase1Query = phaseHistoryRepository + .createQueryBuilder() + .softDelete() + .where('villageId = :villageId', { villageId: village.id }) + .andWhere('phase = :phase', { phase: VillagePhase.DISCOVER }); + await endPhase1Query.execute(); + } +}; +export const phaseHistoryController = new Controller('/phase-history'); + +phaseHistoryController.post({ path: '/' }, async (_req, res) => { + const data = _req.body; + try { + //Find village wit a given id + const villageRepository = AppDataSource.getRepository(Village); + const village = await villageRepository.findOne({ where: { id: data.villageId } }); + if (!village) throw new Error('Village Not found'); + + await handlePhase1toPhase3Case(village, data.phase); + + // Create a PhaseHistory instance and fill it with data + const phaseHistory = new PhaseHistory(); + phaseHistory.village = village; + phaseHistory.phase = data.phase; + phaseHistory.startingOn = new Date(); + + // Save phase history in db + const phaseHistoryRepository = AppDataSource.getRepository(PhaseHistory); + await phaseHistoryRepository.save(phaseHistory); + res.sendStatus(200); + } catch (e) { + console.error(e); + } +}); + +phaseHistoryController.delete({ path: '/soft-delete/:villageId/:phase' }, async (_req, res) => { + const { villageId, phase } = _req.params; + const phaseHistoryRepository = AppDataSource.getRepository(PhaseHistory); + const query = phaseHistoryRepository + .createQueryBuilder() + .softDelete() + .where('villageId = :villageId', { villageId }) + .andWhere('phase = :phase', { phase }); + + try { + await query.execute(); + res.sendStatus(202); + } catch (e) { + console.error(e); + } +}); diff --git a/server/controllers/statistics.ts b/server/controllers/statistics.ts index 72e8a5de9..500a2b748 100644 --- a/server/controllers/statistics.ts +++ b/server/controllers/statistics.ts @@ -63,10 +63,11 @@ statisticsController.get({ path: '/onevillage' }, async (_req, res) => { statisticsController.get({ path: '/villages/:villageId' }, async (_req, res) => { const villageId = parseInt(_req.params.villageId); + const phase = _req.query.phase as unknown as number; res.sendJSON({ - familyAccountsCount: await getFamilyAccountsCount(villageId), - childrenCodesCount: await getChildrenCodesCount(villageId), - connectedFamiliesCount: await getConnectedFamiliesCount(villageId), + familyAccountsCount: await getFamilyAccountsCount(villageId, phase), + childrenCodesCount: await getChildrenCodesCount(villageId, phase), + connectedFamiliesCount: await getConnectedFamiliesCount(villageId, phase), familiesWithoutAccount: await getFamiliesWithoutAccount(villageId), floatingAccounts: await getFloatingAccounts(villageId), }); diff --git a/server/entities/phaseHistory.ts b/server/entities/phaseHistory.ts new file mode 100644 index 000000000..fdd73dd9d --- /dev/null +++ b/server/entities/phaseHistory.ts @@ -0,0 +1,25 @@ +import { Column, CreateDateColumn, DeleteDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; + +import type { VillagePhase } from './village'; +import { Village } from './village'; + +@Entity() +@Index('IDX_PHASE_HISTORY', ['village', 'phase'], { unique: true }) +export class PhaseHistory { + @PrimaryGeneratedColumn() + public id: number; + + @ManyToOne(() => Village, (village) => village.phaseHistories, { onDelete: 'CASCADE' }) + village: Village; + + @Column({ + type: 'tinyint', + }) + phase: VillagePhase; + + @CreateDateColumn({ type: 'datetime' }) + public startingOn: Date; + + @DeleteDateColumn({ type: 'datetime' }) + public endingOn: Date; +} diff --git a/server/entities/village.ts b/server/entities/village.ts index 9081e9f51..9d8b8a487 100644 --- a/server/entities/village.ts +++ b/server/entities/village.ts @@ -9,6 +9,7 @@ import { Classroom } from './classroom'; import { Game } from './game'; import { GameResponse } from './gameResponse'; import { Image } from './image'; +import { PhaseHistory } from './phaseHistory'; import { User } from './user'; export { VillagePhase }; @@ -39,6 +40,9 @@ export class Village implements VillageInterface { }) public activePhase: number; + @OneToMany(() => PhaseHistory, (phaseHistory) => phaseHistory.village, { eager: true }) + public phaseHistories: PhaseHistory[]; + @OneToOne(() => Activity, { onDelete: 'SET NULL' }) @JoinColumn({ name: 'anthemId' }) public anthem: Activity | null; diff --git a/server/migrations/1731486908522-AddPhaseHistory.ts b/server/migrations/1731486908522-AddPhaseHistory.ts new file mode 100644 index 000000000..79b9081dc --- /dev/null +++ b/server/migrations/1731486908522-AddPhaseHistory.ts @@ -0,0 +1,82 @@ +import { Table, TableForeignKey, TableIndex } from 'typeorm'; +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPhaseHistory1731486908522 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'phase_history', + columns: [ + { + name: 'id', + type: 'int', + isPrimary: true, + isGenerated: true, + generationStrategy: 'increment', + }, + { + name: 'villageId', + type: 'int', + isNullable: false, + }, + { + name: 'phase', + type: 'tinyint', + }, + { + name: 'startingOn', + type: 'datetime', + }, + { + name: 'endingOn', + type: 'datetime', + isNullable: true, + }, + ], + }), + ); + + await queryRunner.createIndex( + 'phase_history', + new TableIndex({ + name: 'IDX_PHASE_HISTORY', + columnNames: ['villageId', 'phase'], + isUnique: true, + }), + ); + + await queryRunner.createForeignKey( + 'phase_history', + new TableForeignKey({ + columnNames: ['villageId'], + referencedColumnNames: ['id'], + referencedTableName: 'village', + onDelete: 'CASCADE', + }), + ); + + const villages = await queryRunner.query(`SELECT id FROM village`); + const startingOn2024Phase1Date = '2024-09-30 00:00:00.000000'; + + for (const village of villages) { + await queryRunner.query(`INSERT INTO phase_history (villageId, phase, startingOn, endingOn) VALUES (?, ?, ?, ?)`, [ + village.id, + 1, + startingOn2024Phase1Date, + null, + ]); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('phase_history'); + if (table) { + const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.indexOf('villageId') !== -1); + if (foreignKey) { + await queryRunner.dropForeignKey('phase_history', foreignKey); + await queryRunner.dropIndex('phase_history', 'IDX_PHASE_HISTORY'); + await queryRunner.dropTable('phase_history'); + } + } + } +} diff --git a/server/stats/villageStats.ts b/server/stats/villageStats.ts index 43a526e87..4a41b73d9 100644 --- a/server/stats/villageStats.ts +++ b/server/stats/villageStats.ts @@ -1,38 +1,89 @@ +import { PhaseHistory } from '../entities/phaseHistory'; import { Student } from '../entities/student'; import { User } from '../entities/user'; +import { Village, VillagePhase } from '../entities/village'; import { AppDataSource } from '../utils/data-source'; const userRepository = AppDataSource.getRepository(User); const studentRepository = AppDataSource.getRepository(Student); +const phaseHistoryRepository = AppDataSource.getRepository(PhaseHistory); +const villageRepository = AppDataSource.getRepository(Village); -export const getChildrenCodesCount = async (villageId?: number) => { - const query = studentRepository.createQueryBuilder('student').innerJoin('student.classroom', 'classroom').innerJoin('classroom.village', 'village'); +const getPhasePeriod = async (villageId: number, phase: number): Promise<{ debut: Date | undefined; end: Date | undefined }> => { + // Getting the debut and end dates for the given phase + const query = phaseHistoryRepository + .createQueryBuilder('phaseHistory') + .withDeleted() + .where('phaseHistory.villageId = :villageId', { villageId }) + .andWhere('phaseHistory.phase = :phase', { phase }); + query.select(['phaseHistory.startingOn', 'phaseHistory.endingOn']); + const result = await query.getOne(); + const debut = result?.startingOn; + const end = result?.endingOn; + return { + debut, + end, + }; +}; + +const phaseWasSelected = (phase: number | undefined): boolean => { + return phase !== undefined && Object.values(VillagePhase).includes(+phase); +}; - if (villageId) query.where('classroom.villageId = :villageId', { villageId }); +export const getChildrenCodesCount = async (villageId?: number, phase?: number) => { + const query = studentRepository.createQueryBuilder('student').innerJoin('student.classroom', 'classroom').innerJoin('classroom.village', 'village'); + const village = await villageRepository.findOne({ where: { id: villageId } }); + if (villageId) { + query.andWhere('classroom.villageId = :villageId', { villageId }); + if (phaseWasSelected(phase)) { + const phaseValue = phase as number; + const { debut, end } = await getPhasePeriod(villageId, phaseValue); + query.andWhere('student.createdAt >= :debut', { debut }); + if (phaseValue != village?.activePhase) query.andWhere('student.createdAt <= :end', { end }); + } + } const childrenCodeCount = await query.getCount(); return childrenCodeCount; }; -export const getFamilyAccountsCount = async (villageId?: number) => { +export const getFamilyAccountsCount = async (villageId?: number, phase?: number) => { + const village = await villageRepository.findOne({ where: { id: villageId } }); const query = userRepository .createQueryBuilder('user') .innerJoin('user.village', 'village') .innerJoin('classroom', 'classroom', 'classroom.villageId = village.id') .innerJoin('student', 'student', 'student.classroomId = classroom.id'); - if (villageId) query.where('classroom.villageId = :villageId', { villageId }); + if (villageId) { + query.andWhere('classroom.villageId = :villageId', { villageId }); + if (phaseWasSelected(phase)) { + const phaseValue = phase as number; + const { debut, end } = await getPhasePeriod(villageId, phaseValue); + query.andWhere('user.createdAt >= :debut', { debut }); + if (phaseValue != village?.activePhase) query.andWhere('student.createdAt <= :end', { end }); + } + } query.groupBy('user.id'); const familyAccountsCount = await query.getCount(); return familyAccountsCount; }; -export const getConnectedFamiliesCount = async (villageId?: number) => { +export const getConnectedFamiliesCount = async (villageId?: number, phase?: number) => { + const village = await villageRepository.findOne({ where: { id: villageId } }); const query = studentRepository .createQueryBuilder('student') .innerJoin('classroom', 'classroom', 'classroom.id = student.classroomId') - .where('student.numLinkedAccount >= 1'); - if (villageId) query.andWhere('classroom.villageId = :villageId', { villageId }); + .andWhere('student.numLinkedAccount >= 1'); + if (villageId) { + query.andWhere('classroom.villageId = :villageId', { villageId }); + if (phaseWasSelected(phase)) { + const phaseValue = phase as number; + const { debut, end } = await getPhasePeriod(villageId, phaseValue); + query.andWhere('student.createdAt >= :debut', { debut }); + if (phaseValue != village?.activePhase) query.andWhere('student.createdAt <= :end', { end }); + } + } const connectedFamiliesCount = await query.getCount(); diff --git a/src/api/phaseHistory/phaseHistory.delete.ts b/src/api/phaseHistory/phaseHistory.delete.ts new file mode 100644 index 000000000..9138ccf73 --- /dev/null +++ b/src/api/phaseHistory/phaseHistory.delete.ts @@ -0,0 +1,15 @@ +import type { PhaseHistory } from 'server/entities/phaseHistory'; + +import { axiosRequest } from 'src/utils/axiosRequest'; + +export async function softDeletePhaseHistory(villageId: number, phase: number): Promise { + const response = await axiosRequest({ + method: 'DELETE', + url: `/phase-history/soft-delete/${villageId}/${phase}`, + }); + if (response.error) { + throw new Error("Erreur lors de la suppresion de l'historique des phases. Veuillez réessayer."); + } + + return response.data; +} diff --git a/src/api/phaseHistory/phaseHistory.post.ts b/src/api/phaseHistory/phaseHistory.post.ts new file mode 100644 index 000000000..249f97383 --- /dev/null +++ b/src/api/phaseHistory/phaseHistory.post.ts @@ -0,0 +1,16 @@ +import type { PhaseHistory } from 'server/entities/phaseHistory'; + +import { axiosRequest } from 'src/utils/axiosRequest'; + +export async function postPhaseHistory(data: Partial & { villageId: number }): Promise { + const response = await axiosRequest({ + method: 'POST', + url: '/phase-history', + data, + }); + if (response.error) { + throw new Error("Erreur lors de la création de l'historique des phases. Veuillez réessayer."); + } + + return response.data; +} diff --git a/src/api/statistics/statistics.get.ts b/src/api/statistics/statistics.get.ts index a7e589dd6..037934e8b 100644 --- a/src/api/statistics/statistics.get.ts +++ b/src/api/statistics/statistics.get.ts @@ -23,12 +23,12 @@ async function getOneVillageStats(): Promise { ).data; } -async function getVillagesStats(villageId: number | null): Promise { +async function getVillagesStats(villageId: number | null, phase: number): Promise { return ( await axiosRequest({ method: 'GET', baseURL: '/api', - url: `/statistics/villages/${villageId}`, + url: phase ? `/statistics/villages/${villageId}?phase=${phase}` : `/statistics/villages/${villageId}`, }) ).data; } @@ -40,8 +40,8 @@ export const useGetOneVillageStats = () => { return useQuery(['1v-stats'], () => getOneVillageStats()); }; -export const useGetVillagesStats = (villageId: number | null) => { - return useQuery(['villages-stats', villageId], () => getVillagesStats(villageId), { +export const useGetVillagesStats = (villageId: number | null, phase: number) => { + return useQuery(['villages-stats', villageId, phase], () => getVillagesStats(villageId, phase), { enabled: villageId !== null, }); }; diff --git a/src/components/admin/dashboard-statistics/VillageStats.tsx b/src/components/admin/dashboard-statistics/VillageStats.tsx index 6df9b0563..9e66c70b7 100644 --- a/src/components/admin/dashboard-statistics/VillageStats.tsx +++ b/src/components/admin/dashboard-statistics/VillageStats.tsx @@ -8,6 +8,7 @@ import { OneVillageTable } from '../OneVillageTable'; import TabPanel from './TabPanel'; import StatsCard from './cards/StatsCard/StatsCard'; import CountriesDropdown from './filters/CountriesDropdown'; +import PhaseDropdown from './filters/PhaseDropdown'; import VillageDropdown from './filters/VillageDropdown'; import { PelicoCard } from './pelico-card'; import styles from './styles/charts.module.css'; @@ -24,11 +25,12 @@ const VillageStats = () => { const [selectedVillage, setSelectedVillage] = useState(''); const [options, setOptions] = useState({ countryIsoCode: '' }); const [value, setValue] = React.useState(0); + const [selectedPhase, setSelectedPhase] = React.useState(0); const { countries } = useCountries(); const { villages } = useVillages(options); - const villagesStats = useGetVillagesStats(+selectedVillage); + const villagesStats = useGetVillagesStats(+selectedVillage, selectedPhase); React.useEffect(() => { setOptions({ countryIsoCode: selectedCountry, @@ -51,6 +53,10 @@ const VillageStats = () => { setSelectedVillage(village); }; + const handlePhaseChange = (phase: string) => { + setSelectedPhase(+phase); + }; + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { setValue(newValue); }; @@ -70,6 +76,9 @@ const VillageStats = () => { gap: 2, }} > +
+ +
diff --git a/src/components/admin/dashboard-statistics/filters/PhaseDropdown.tsx b/src/components/admin/dashboard-statistics/filters/PhaseDropdown.tsx index d530893e0..683f5b98f 100644 --- a/src/components/admin/dashboard-statistics/filters/PhaseDropdown.tsx +++ b/src/components/admin/dashboard-statistics/filters/PhaseDropdown.tsx @@ -12,7 +12,7 @@ interface PhaseDropdownProps { } export default function PhaseDropdown({ onPhaseChange }: PhaseDropdownProps) { - const [phase, setPhase] = React.useState('4'); + const [phase, setPhase] = React.useState('0'); const handleChange = (event: SelectChangeEvent) => { const selectedPhase = event.target.value as string; @@ -25,7 +25,7 @@ export default function PhaseDropdown({ onPhaseChange }: PhaseDropdownProps) { Phase