Construire votre démarrage Terminer la planification de groupe

Ce que vous allez créer

Ce tutoriel fait partie de la Construire votre démarrage avec la série PHP sur Envato Tuts +. Dans cette série, je vous guide dans le lancement d’une startup du concept à la réalité en utilisant mes Planificateur de réunion application comme exemple de la vie réelle. À chaque étape du processus, je publierai le code de Meeting Planner sous forme d’exemples open source à partir desquels vous pourrez apprendre. Je traiterai également les problèmes liés au démarrage au fur et à mesure qu'ils surviennent.

Développement continu des réunions de groupe

Bienvenue! Ceci est l'épisode de suivi de Construire son démarrage: réunions avec plusieurs participants. Aujourd’hui, je vais terminer le travail que nous avons commencé dans cet épisode: organiser des réunions avec plusieurs participants..

Un bref rappel

La planification de réunions avec plusieurs participants a toujours été un objectif majeur de Meeting Planner, qui a été lancé avec une planification 1: 1 seulement. Les réunions à plusieurs participants sont les tâches les plus difficiles à planifier entre elles et constituent donc l'une des fonctionnalités les plus utiles du service Meeting Planner.. 

Dans le tutoriel d'aujourd'hui, je vais couvrir la révision de toutes les zones du site affectées par plusieurs réunions de participants, le traitement et l'affichage intelligent de listes de destinataires de statut différent, la gestion correcte des notifications et le filtrage des notifications pour les groupes, et enfin la mise à niveau de la demande lancée récemment. fonction de modification de réunion.

Planifiez votre première réunion de groupe

Veuillez planifier votre propre réunion de groupe aujourd'hui! Invitez quelques amis à vous rencontrer pour le kombucha ou le kava. Partagez vos impressions et commentaires sur l'expérience de chacun dans les commentaires ci-dessous. Je participe aux discussions, mais vous pouvez également me joindre à @reifman sur Twitter. Je suis toujours ouvert aux nouvelles idées de fonctionnalités de Meeting Planner ainsi qu'aux suggestions pour les futurs épisodes de la série..

Pour rappel, tout le code de Meeting Planner est fourni en open source et écrit dans le framework Yii2 pour PHP. Si vous souhaitez en savoir plus sur Yii2, consultez mes séries parallèles Programmer avec Yii2. 

Réviser le code

Comme vous pouvez l’imaginer, transformer le planificateur de réunions de réunions 1: 1 en réunions de groupes a touché presque tout le code. Je devais réfléchir à tous les domaines, réviser le code et apporter des changements mineurs à modérés dans de nombreux endroits. Dans d’autres domaines, j’ai conçu pour plusieurs participants et les modifications n’étaient pas nécessaires ou étaient mineures..

Par exemple, la planification de groupe a touché:

  • Envoi d'invitations et finalisation (confirmation) des programmes de réunion
  • Apporter des modifications, renvoyer, répéter, reprogrammer une réunion
  • Envoi d'un avis de retard
  • Rappels de réunion
  • Demander des modifications à la réunion
  • Les notifications

La plupart du temps, là où je venais d’envoyer au participant [0], premier et unique participant, il me fallait maintenant traiter l’ensemble des participants. Et, ce faisant, je devais vérifier:

  • Ce participant est-il un organisateur??
  • Cette personne a-t-elle été renvoyée ou refusée d'elle-même??
  • Est-ce que cette personne a choisi de ne plus recevoir les notifications pour cette réunion??

Les défis du test

Avec plus de ressources, j'aurais pu gérer cela de manière plus complète avec des tests automatisés. Cependant, travaillant en solo avec le but de l'expédition, j'ai tout testé manuellement de manière exhaustive..

J'ai utilisé un e-mail de domaine captivant pour pouvoir inviter n1, n2, n3, n4 et n5 @ mytestdomain.com à mes réunions de groupe. Heureusement, les invitations de Meeting Planner facilitent la connexion rapide à n'importe quel compte en cliquant sur chaque invitation à la réunion. cela a aidé mon test.

Il était important de revoir presque tout le code de planification de réunion. 

Mais revenons aux défis de codage plus spécifiques de la seconde moitié de la fonctionnalité de planification de groupe.

Affichage intelligent des listes de participants

Il y a quelque temps, j'ai construit un DiversHelpers méthode permettant d’afficher correctement les listes en anglais avec "et" avant le nom de famille, comme indiqué dans l’index des réunions ci-dessous:

Cependant, je voulais simplifier l'affichage de la date, de l'heure et de la disponibilité des lieux. Par exemple, plutôt que d’énumérer cinq noms de personnes qui ont accepté de se rencontrer à Herkimer Coffee, j’ai mis à jour MiscHelpers :: listNames pour dire "tout le monde":

fonction statique publique listNames ($ items, $ everyoneElse = false, $ total_count = 0, $ anyElse = false) $ temp = "; $ x = 1; $ cnt = nombre ($ items); if ($ everyoneElse && $ cnt > = ($ total_count-1)) if (! $ anyElse) $ temp = Yii :: t ('frontend', 'tout le monde'); else $ temp = Yii :: t ('frontend', 'quelqu'un d'autre'); else foreach ($ items comme $ i) $ temp. = MiscHelpers :: getDisplayName ($ i); if ($ x == ($ cnt-1)) $ temp. = 'et'; sinon si ($ x < ($cnt-1))  $temp.=', ';  $x+=1;   return $temp; 

Vous pouvez voir ceci en action ci-dessous:

Mais plutôt que de dire "Pas de réponse de tout le monde", il est plus approprié de dire "Pas de réponse de quelqu'un d'autre", ce que le code fait.

Ci-dessous, vous pouvez voir MeetingPlace :: getWhereStatus () préparer ces chaînes pour chaque lieu du panneau des lieux de réunion:

fonction statique publique getWhereStatus ($ meeting, $ viewer_id) // récupère un tableau de statut textuel des lieux de réunion pour $ viewer_id // Acceptable / Rejeté / Aucune réponse: $ whereStatus ['style'] = []; $ whereStatus ['text'] = []; foreach ($ meeting-> meetingPlaces as $ mp) // statut de construction pour chaque lieu $ acceptableChoice = []; $ rejetéChoix = []; $ unknownChoice = []; // à faire - ajoutez meeting_id à MeetingPlaceChoice pour les requêtes triables pour chaque requête ($ mp-> meetingPlaceChoice en tant que $ mpc) if ($ mpc-> user_id == $ viewer_id) continue; switch ($ mpc-> status) case MeetingPlaceChoice :: STATUS_UNKNOWN: $ unknownChoice [] = $ mpc-> user_id; Pause; case MeetingPlaceChoice :: STATUS_YES: $ acceptableChoice [] = $ mpc-> user_id; Pause; case MeetingPlaceChoice :: STATUS_NO: $ rejetéChoix [] = $ mpc-> utilisateur_id; Pause;  // à faire - intégrer le paramètre actuel de cet utilisateur dans le paramètre de style $ temp = "; // compte le nombre de personnes toujours présentes $ cntP = Participant :: find () -> where (['meeting_id' => $ meeting- > id]) -> andWhere (['status' => Participant :: STATUS_DEFAULT]) -> count () + 1; if (count ($ acceptableChoice)> 0) $ temp. = 'Acceptable to' .MiscHelpers: : listNames ($ acceptableChoice, true, $ cntP). '.'; $ whereStatus ['style'] [$ mp-> place_id] = 'success'; if (count ($ rejetéChoice)> 0) $ temp. = 'Rejeté par' .MiscHelpers :: listNames ($ rejetéChoice, true, $ cntP). '.'; $ OùStatus ['style'] [$ mp-> lieu_id] = 'danger'; if (count ($ unknownChoice )> 0) $ temp. = 'Pas de réponse de' .MiscHelpers :: listNames ($ unknownChoice, true, $ cntP, true). '.'; '; $ OùStatus [' style '] [$ mp-> place_id] = 'warning'; $ whereStatus ['text'] [$ mp-> lieu_id] = $ temp; return $ whereStatus;

Chaque utilisateur a un MeetingPlaceChoice rangée liée à un Lieu de rencontre qui enregistre si un lieu est acceptable, pas acceptable ou pas encore répondu. MeetingTimeChoice existe également de manière similaire. Cette information est transmise à listNames ().

Décliner et se retirer d'une réunion

Les groupes ont également besoin de plus de complexité pour refuser une invitation à une réunion. Auparavant, si un participant refusait, la réunion était effectivement annulée. Maintenant, on pourrait refuser, laissant trois autres pour continuer.

Ainsi, si un participant reçoit une invitation à une réunion, il peut Déclin il. Mais si la réunion a déjà été confirmée et finalisée, alors ils sont essentiellement Retrait comme vous pouvez le voir ci-dessous:

Remarque: dans l'image ci-dessus, c'est Sarah Smithers voir le Se désister bouton; Robert McSmith avait été retiré par un autre organisateur, soit Jeff ou Alex.

Cependant, s’il s’agit d’un organisateur (organisateur de la réunion ou organisateur participant oint), il peut simplement Annuler la réunion. Ci-dessous est tiré de _command_bar_confirmed.php. Il détermine quels boutons présenter:

if (! $ isPast) if ($ model-> isOrganizer ()) echo Html :: a (' '.Yii :: t (' frontend ',' Cancel '), [' cancel ',' id '=> $ model-> id], [' class '=>' btn btn-primaire btn-danger ',' title '=> Yii :: t (' frontend ',' Cancel '),' data-confirm '=> Yii :: t (' frontend ',' Êtes-vous sûr de vouloir annuler cette réunion? ')]);  else if ($ model-> getParticipantStatus (Yii :: $ app-> user-> getId ()) == Participant :: STATUS_DEFAULT) echo Html :: a (' '.Yii :: t (' frontend ',' Withdraw '), [' declin ',' id '=> $ model-> id], [' class '=>' btn btn-primaire btn-danger ',' title '=> Yii :: t (' frontend ',' Se retirer de la réunion '),' data-confirm '=> Yii :: t (' frontend ',' Êtes-vous sûr de vouloir refuser la participation à cette réunion? ')]);  else // à faire - offre la possibilité de rejoindre une réunion

La priorisation est un élément clé de la création d’une startup. Ainsi, alors que je voulais offrir un moyen à un utilisateur qui s'était retiré de rejoindre une réunion, j'ai décidé de l'ajouter à la liste des tâches Asana pour plus tard. Un utilisateur qui rejoint le groupe aurait besoin de notifications mises à jour et éventuellement de mises à jour de ses structures de données de planification..

Réfléchir aux notifications

Alors qu'avec les réunions 1: 1, chaque changement devait être envoyé à l'autre partie, cela n'avait pas nécessairement de sens pour les réunions à 12 personnes - ou l'a-t-il fait? Ça dépend.

Au départ, j'ai créé des directives générales. Si un participant met à jour ses préférences pour une date ou une heure spécifique, seul le propriétaire et les autres organisateurs doivent être mis à jour à ce sujet..

J'ai créé un tableau $ groupSkip dans Journal de réunion quels événements déterminés ne doivent pas être envoyés à d'autres participants:

public static $ groupSkip = [MeetingLog :: ACTION_ACCEPT_ALL_PLACES, MeetingLog :: ACTION_ACCEPT_PLACE, MeetingLog :: ACTION_REJECT_PLACE, MeetingLog :: ACTION_ACCEPT_ALL_TIMES, MeetingLog :: ACTION_ACCEPT_TIME, MeetingLog :: ACTION_REJECT

Dans MeetingLog :: getHistory, nous omettons de notifier un participant pour ces événements, mais en avertissons toujours les organisateurs:

if (… // ignore les événements de réponse à la disponibilité dans les réunions à plusieurs participants ($ isGroup &&! $ isOrganizer && in_array ($ e-> action, MeetingLog :: $ groupSkip))) $ num_events- = 1; // ignore l'événement, réduit le nombre d'événements continue; 

Dans un exemple inhabituel, le code a été simplifié avec plusieurs participants: Réunion :: findFresh (), qui recherche les mises à jour des modifications de réunion à partager par courrier électronique. 

Auparavant, nous devions identifier lequel des deux utilisateurs effectuait les actions et s'il fallait notifier ou non. Maintenant, nous en avisons simplement le propriétaire, puis les participants:

if ((time () - $ m-> log_at))> MeetingLog :: TIMELAPSE && $ m-> status> = Meeting :: STATUS_SENT) // // récupère les éléments enregistrés après le dernier cleared_at $ m-> notify ( $ m-> id, $ m-> owner_id); // informe les participants pour chaque ($ m-> participants sous forme de $ p) // ne met pas à jour les participants supprimés et refusés si ($ p-> status! = Participant :: STATUS_DEFAULT) continue;  // echo 'Notify P-id:'. $ p-> participant_id. '
'; $ m-> notify ($ m-> id, $ p-> participant_id);

Tout filtrage est effectué plus en profondeur dans la textualisation du journal des événements.

Notifications améliorées: "Tout le monde est disponible!"

J'ai également créé une nouvelle notification pour alerter les organisateurs lorsque tout le monde est d'accord avec au moins un lieu et une heure spécifiques., MeetingLog :: ACTION_SEND_EVERYONE_AVAILABLE:

// vérifie si la réunion a lieu et l'heure pour tout le monde maintenant si (nombre ($ m-> participants)> 1 &&! MeetingLog :: hasEventOccourt ($ m-> id, MeetingLog :: ACTION_SEND_EVERYONE_AVAILABLE) && Meeting :: isEveryoneAvailable ($ m -> id)) Meeting :: notifyOrganizers ($ m-> id, MeetingLog :: ACTION_SEND_EVERYONE_AVAILABLE); MeetingLog :: add ($ m-> id, MeetingLog :: ACTION_SEND_EVERYONE_AVAILABLE, 0); 

Ceci avertit les organisateurs lorsque la réunion est prête à finaliser / confirmer.

Voici le code qui examine tous les lieux et heures de réunion pour voir si tout le monde est d'accord pour au moins un lieu:

fonction statique publique isEveryoneAvailable ($ meeting_id) // vérifie qu'un lieu fonctionne pour toutes les personnes présentes $ m = Meeting :: findOne ($ meeting_id); $ cntAll = $ m-> countAttendingParticipants (true); // compte organisateur + participants participants $ mpExists = false; $ mtExists = true; $ mps = \ frontend \ models \ MeetingPlace :: find () -> où (['meeting_id' => $ meeting_id]) -> all (); foreach ($ mps en $ mp) $ cnt = 0; foreach ($ mp-> meetingPlaceChoices en tant que $ mpc) if ($ m-> getParticipantStatus ($ mpc-> user_id)! = Participant :: STATUS_DEFAULT) // sauter le retrait, refuser, les participants supprimés continuent;  if ($ mpc-> status == \ frontend \ models \ MeetingPlaceChoice :: STATUS_YES) $ cnt + = 1;  if ($ cnt> = $ cntAll) $ mpExists = true;  $ mts = \ frontend \ models \ MeetingTime :: find () -> où (['meeting_id' => $ meeting_id]) -> all (); foreach ($ mts en $ mt) $ cnt = 0; foreach ($ mt-> meetingTimeChoices as $ mtc) if ($ m-> getParticipantStatus ($ mtc-> user_id)! = Participant :: STATUS_DEFAULT) // ignorer le retrait, le refus, le retrait des participants continuent;  if ($ mtc-> status == \ frontend \ models \ MeetingTimeChoice :: STATUS_YES) $ cnt + = 1;  if ($ cnt> = $ cntAll) $ mtExists = true;  // au moins une fois et un lieu fonctionne pour toutes les personnes présentes si (($ mpExists && $ mtExists) return true;  else return false;  

De même, j’ai créé une fonction qui permet d’annoncer à l’organisateur qu’aucune date, heure et lieu ne sont acceptables pour personne., Réunion :: isSomeoneAvailable ():

si ($ model-> status <= Meeting::STATUS_SENT)  if ($model->isOrganizer () && ($ modèle-> statut == Réunion :: STATUS_SENT) &&! $ modèle-> isSomeoneAvailable ()) Yii :: $ app-> getSession () -> setFlash ('danger', Yii :: t ('frontend', 'Aucun des participants n’est disponible pour les options actuelles de la réunion.')); 

Cela indique qu'ils doivent suggérer des dates et / ou des lieux supplémentaires.

Mise à jour des rappels de réunion

Tout ce qui concernait les rappels de réunion fonctionnait bien pour plusieurs participants, mais je devais les désactiver si un participant avait refusé, s'était retiré d'une réunion ou avait été supprimé:

$ cnt = 1; foreach ($ mt-> participants en tant que $ p) if ($ p-> statut == Participant :: STATUS_DEFAULT) $ participants [$ cnt] = $ p-> participant_id; $ cnt + = 1;  

STATUS_DEFAULTindique un participant qui doit également être ajouté à la liste des utilisateurs pour envoyer des rappels par courrier électronique. 

Examen des fichiers de calendrier

J'ai également examiné le travail de génération de fichiers de calendrier pour les invitations afin de m'assurer que tous les participants sont inclus. Dans Réunion :: prepareDownloadIcs (), J'ai constitué un groupe de participants avec le propriétaire et les participants participant activement à:

$ participants = tableau (); foreach ($ m-> participants en tant que $ p) if ($ p-> statut == Participant :: STATUS_DEFAULT) $ clé_auth = \ commun \ modèles \ Utilisateur :: find () -> où (['id' = > $ p-> participant_id]) -> one () -> auth_key; $ participants [$ cnt] = ['user_id' => $ p-> participant_id, 'auth_key' => $ auth_key, 'email' => $ p-> participant-> email, 'nomutilisateur' => $ p-> participant-> nom d'utilisateur]; $ cnt + = 1; // échange l'amitié avec l'organisateur \ frontend \ models \ Friend :: add ($ p-> participant_id, $ p-> guest_by); // à faire - réciproque amitié lors de réunions multi-participants $ auth_key = \ common \ models \ User :: find () -> Où (['id' => $ m-> propriétaire_id]) -> one () - > clé_auth; $ participants [$ cnt] = ['user_id' => $ m-> propriétaire_id, 'auth_key' => $ auth_key, 'email' => $ m-> propriétaire-> email, 'nomutilisateur' => $ m-> propriétaire-> nom d'utilisateur]; foreach ($ participants comme $ cnt => $ a) if ($ a ['user_id'] == $ actor_id) $ icsPath = Meeting :: buildCalendar ($ m-> id, $ selectedPlace, $ selectedTime, $ a , $ participants);

Pendant ce temps, j'ai également appris à indiquer qu'un fichier de calendrier d'une réunion annulée devait entraîner la suppression de l'événement du calendrier de quelqu'un. Le standard ics est puissant mais difficile à maîtriser.

Mise à jour des modifications de demande pour les groupes

Comme je l'ai écrit récemment, la fonctionnalité Demander des modifications de la réunion a demandé beaucoup de travail et de nouvelles UX..

Pour plusieurs réunions de participants, l'ingénierie sociale devait encore changer. Par exemple, les organisateurs peuvent accepter ou refuser les demandes et modifier le calendrier des réunions. Toutefois, les participants ne peuvent exprimer que des demandes de modification, par exemple «J'aime», «N'aime pas ou pas». Et les organisateurs devraient voir toutes les réponses des participants pour les aider dans leur prise de décision.

Voici ce que le participant voit après avoir soumis sa demande de modification:

De nouvelles demandes de changement doivent être envoyées à tous les participants. Ceci est géré de manière transparente par les notifications du journal d'activité. Lorsqu'une demande est faite, cet événement est créé dans RequestController :: actionCreate () soumettre:

MeetingLog :: add ($ model-> meeting_id, MeetingLog :: ACTION_REQUEST_CREATE, Yii :: $ app-> user-> getId (), $ model-> id);

Voici à quoi ressemble la notification de modification demandée aux autres participants:

Tout le monde est invité à répondre. En cliquant Répondre à une requête saute directement à la demande. Ou vous pouvez le trouver dans la liste des demandes du lien d'alerte flash de la réunion indiquée ci-dessus..

Nouvel UX pour les participants répondant aux demandes

Voici le formulaire que les participants voient lorsqu'ils répondent à une demande:

S'il y a déjà d'autres réponses des participants, ils les verront:

Voici la partie supérieure de ce formulaire dans /frontend/views/request-response/_form.php:

$ responseProvider, 'columns' => [['label' => 'Réponses des autres participants', 'attribut' => 'responder_id', 'format' => 'raw', 'value' => function ($ model) $ note = "; if (! vide ($ modèle-> note)) $ note = 'a dit,' '. $ modèle-> note.' ''; retour '
'.MiscHelpers :: getDisplayName ($ model-> responder_id). ". $ Model-> lookupOpinion (). $ Note.'
'; ,],],]); ?>
field ($ model, 'note') -> label (Yii :: t ('frontend', 'Inclure une note')) -> textarea (['rows' '=> 6]) -> hint (Yii :: t ('frontend', 'optionnel'))?>

le Gridview liste les réponses existantes, par ex. J'aime, n'aime pas, neutre, et toute note personnelle attachée.

Ensuite, voici le code pour la moitié inférieure du formulaire, qui affichera Comme, n'aime pas, ne t'en fais pas aux participants mais Acceptez et Faire des changements ou Demande de refus aux propriétaires.

 

'btn btn-success', 'name' => 'accepter',])?> 'btn btn-danger', 'nom' => 'rejeter', 'données' => ['confirmer' => Yii :: t ('interface', 'Êtes-vous sûr de vouloir refuser cette demande?'), 'méthode' => 'post',],])?>

'btn btn-success', 'name' => 'like',])?> 'btn btn-info', 'name' => 'neutre',])?> 'btn btn-danger', 'name' => 'n'aime pas',])?>

Les participants qui sont des organisateurs oints reçoivent les deux ensembles de boutons et peuvent soit exprimer leur opinion, soit apporter ou refuser le changement..

Voici la notification par email qu'un changement a été accepté:

Bien sûr, une invitation mise à jour et des fichiers de calendrier seront envoyés à tout le monde si une modification est apportée.

Toujours plus d'améliorations à faire

J'espère que vous avez apprécié ces deux épisodes (aujourd'hui et Bâtissez votre démarrage: réunions avec plusieurs participants). En mode de démarrage avec une énorme nouvelle fonctionnalité, il y a toujours un effort de lancement ciblé et rationalisé, qui laisse de nombreux vides non résolus et les défauts non résolus.

Quelques exemples de ceci incluent:

  • Réunions que vous avez refusées ou auxquelles vous vous êtes retiré.
  • Meilleure présentation de la liste des participants dans les invitations.
  • Options permettant de garder la liste des participants et / ou leurs statuts individuels à l'abri des autres participants.
  • Traitement et présentation améliorés des informations de contact du groupe et des détails de la conférence virtuelle, par exemple. une ligne de conférence et un code de participation.
  • URL sécurisée pour le partage d'invitations à des réunions. Cela permettrait aux organisateurs de partager une URL sur Facebook ou par courrier électronique pour inviter de nouveaux participants..

Malgré ces lacunes, j'ai travaillé d'arrache-pied pour atteindre ce niveau de fonctionnalité et de convivialité pour Meeting Planner. Je suis super enthousiasmé par ses progrès et entend de bons commentaires à ce sujet de la part de mes amis et collègues. 

Je vous présente ces deux tutoriels aujourd'hui et je passe quelques jours hors ligne dans la forêt pour vous arrêter. Le repos est important. Être en contact avec la nature aide à vous rappeler ce qui est important dans la vie: les startups ne le sont pas toujours. Ils peuvent être des activités créatives, importantes pour nos revenus et notre carrière, ils peuvent parfois aider les gens à vivre de manière plus efficace et productive, mais ils sont souvent éloignés de la Terre, d’amitiés et d’aider d’autres personnes moins fortunées. Ce sont toutes des choses auxquelles je pense tous les jours et que je referai en mon absence.

Je me demanderai à plusieurs reprises si je fais tout ce que je veux faire avec mon temps, surtout à la suite de ma chirurgie du cerveau..

Je suis également fier du fait que je suis fier de Meeting Planner, en particulier du travail accompli à ce jour et de son utilité croissante. Dans l'ensemble, la version bêta du service est presque terminée. 

Les réunions à plusieurs participants constituaient l’élément de travail restant le plus décourageant et complexe. À l'avenir, les fonctionnalités et les tâches sont plus modérées, plus petites et plus faciles à gérer. Je suis enthousiasmé par ses perspectives d'avenir!

Si vous ne l'avez pas encore fait, allez planifier votre première réunion avec Meeting Planner maintenant.!

Un tutoriel sur le financement participatif est également en préparation. Veuillez suivre notre page WeFunder Meeting Planner..

Vous pouvez également me contacter @reifman. Je suis toujours ouvert aux nouvelles idées de fonctionnalités et suggestions de sujets pour les prochains tutoriels.

Restez à l'affût de tout cela et de plusieurs autres tutoriels à venir en consultant la série Construire son démarrage avec PHP. 

Liens connexes

  • Planificateur de réunion
  • Suivre le profil de financement de Meeting Planner
  • Programmation avec la série Yii2 (Envato Tuts +)