| @ -1,4 +1,9 @@ | |||
| import './vendor/bootstrap/dist/css/bootstrap.min.css'; | |||
| import './vendor/bootstrap/bootstrap.index.js'; | |||
| import './bootstrap.js'; | |||
| import './vendor/bootstrap/dist/css/bootstrap.min.css'; | |||
| import './vendor/leaflet/dist/leaflet.min.css'; | |||
| import './styles/app.css'; | |||
| import { Tooltip } from './vendor/bootstrap/bootstrap.index.js'; | |||
| const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]') | |||
| const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl)) | |||
| @ -0,0 +1,5 @@ | |||
| import { startStimulusApp } from '@symfony/stimulus-bundle'; | |||
| const app = startStimulusApp(); | |||
| // register any custom, 3rd party controllers here | |||
| // app.register('some_controller_name', SomeImportedController); | |||
| @ -0,0 +1,4 @@ | |||
| { | |||
| "controllers": [], | |||
| "entrypoints": [] | |||
| } | |||
| @ -0,0 +1,44 @@ | |||
| import { Controller } from '@hotwired/stimulus'; | |||
| export default class extends Controller { | |||
| static values = { | |||
| importurl: String, | |||
| layername: String, | |||
| } | |||
| remoteControl() { | |||
| // cf <https://josm.openstreetmap.de/wiki/Help/RemoteControlCommands> | |||
| const baseurl = 'http://localhost:8111'; | |||
| const _this = this; | |||
| var url = baseurl + '/version'; | |||
| fetch(url) | |||
| .then(function (response) { | |||
| return response.json(); | |||
| }) | |||
| .then(function (json) { | |||
| console.log('JOSM v' + json.version); | |||
| url = baseurl + '/imagery?' + (new URLSearchParams({ | |||
| 'id': 'osmfr', // 'fr.orthohr', // cf <https://josm.openstreetmap.de/wiki/Maps> | |||
| })); | |||
| fetch(url) | |||
| .then(function (response) { | |||
| return response.text(); | |||
| }) | |||
| .then(function (text) { | |||
| console.log(text); | |||
| }); | |||
| url = baseurl + '/import?' + (new URLSearchParams({ | |||
| 'new_layer': true, | |||
| 'layer_name': _this.layernameValue, | |||
| 'url': _this.importurlValue, | |||
| })); | |||
| fetch(url) | |||
| .then(function (response) { | |||
| return response.text(); | |||
| }) | |||
| .then(function (text) { | |||
| console.log(text); | |||
| }); | |||
| }); | |||
| } | |||
| } | |||
| @ -0,0 +1,39 @@ | |||
| import { Controller } from '@hotwired/stimulus'; | |||
| import 'leaflet'; | |||
| export default class extends Controller { | |||
| static values = { | |||
| geojson: String, | |||
| } | |||
| connect() { | |||
| var map = L.map(this.element); | |||
| L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { | |||
| maxZoom: 19, | |||
| attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>' | |||
| }).addTo(map); | |||
| var layer = L.featureGroup(); | |||
| var geojsons = JSON.parse(this.geojsonValue); | |||
| if (geojsons.length > 0) { | |||
| geojsons.forEach(function (geojson) { | |||
| const feature0 = geojson.features[0].properties; | |||
| L.geoJSON(geojson, { | |||
| style: function (feature) { | |||
| var color = 'blue'; | |||
| switch (feature.properties.color) { | |||
| case 'danger' : color = '#dc3545'; break | |||
| case 'warning' : color = '#ffc107'; break | |||
| case 'success' : color = '#198754'; break | |||
| } | |||
| return {color: color}; | |||
| } | |||
| }).bindTooltip(feature0.name).addTo(layer).on('click', function (event) { | |||
| window.location.href = event.layer.feature.properties.url; | |||
| }); | |||
| }); | |||
| layer.addTo(map); | |||
| console.log(layer.getBounds().toBBoxString()); | |||
| map.fitBounds(layer.getBounds()); | |||
| } | |||
| } | |||
| } | |||
| @ -0,0 +1,3 @@ | |||
| .min-vh-50 { | |||
| min-height: 50vh; | |||
| } | |||
| @ -0,0 +1,50 @@ | |||
| framework: | |||
| workflows: | |||
| task_lifecycle: | |||
| type: state_machine | |||
| audit_trail: | |||
| enabled: true | |||
| marking_store: | |||
| type: 'method' | |||
| property: 'status' | |||
| supports: | |||
| - App\Entity\Task | |||
| initial_marking: !php/const App\Entity\Task::STATUS_TODO | |||
| places: | |||
| !php/const App\Entity\Task::STATUS_TODO: | |||
| metadata: | |||
| title: 'À faire' | |||
| color: danger | |||
| !php/const App\Entity\Task::STATUS_DOING: | |||
| metadata: | |||
| title: 'En cours' | |||
| color: warning | |||
| !php/const App\Entity\Task::STATUS_DONE: | |||
| metadata: | |||
| title: 'Terminé' | |||
| color: success | |||
| transitions: | |||
| !php/const App\Entity\Task::TRANSITION_START: | |||
| from: !php/const App\Entity\Task::STATUS_TODO | |||
| to: !php/const App\Entity\Task::STATUS_DOING | |||
| metadata: | |||
| title: 'Commencer la tâche' | |||
| route: 'app_task_start' | |||
| !php/const App\Entity\Task::TRANSITION_FINISH: | |||
| from: !php/const App\Entity\Task::STATUS_DOING | |||
| to: !php/const App\Entity\Task::STATUS_DONE | |||
| metadata: | |||
| title: 'Terminer la tâche' | |||
| route: 'app_task_finish' | |||
| !php/const App\Entity\Task::TRANSITION_CANCEL: | |||
| from: !php/const App\Entity\Task::STATUS_DOING | |||
| to: !php/const App\Entity\Task::STATUS_TODO | |||
| metadata: | |||
| title: 'Abandonner la tâche' | |||
| route: 'app_task_cancel' | |||
| !php/const App\Entity\Task::TRANSITION_RESET: | |||
| from: !php/const App\Entity\Task::STATUS_DONE | |||
| to: !php/const App\Entity\Task::STATUS_TODO | |||
| metadata: | |||
| title: 'Recommencer la tâche' | |||
| route: 'app_task_reset' | |||
| @ -1,31 +0,0 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace DoctrineMigrations; | |||
| use Doctrine\DBAL\Schema\Schema; | |||
| use Doctrine\Migrations\AbstractMigration; | |||
| /** | |||
| * Auto-generated Migration: Please modify to your needs! | |||
| */ | |||
| final class Version20240720132139 extends AbstractMigration | |||
| { | |||
| public function getDescription(): string | |||
| { | |||
| return ''; | |||
| } | |||
| public function up(Schema $schema): void | |||
| { | |||
| // this up() migration is auto-generated, please modify it to your needs | |||
| $this->addSql('CREATE TABLE project (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, description CLOB DEFAULT NULL)'); | |||
| } | |||
| public function down(Schema $schema): void | |||
| { | |||
| // this down() migration is auto-generated, please modify it to your needs | |||
| $this->addSql('DROP TABLE project'); | |||
| } | |||
| } | |||
| @ -1,40 +0,0 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace DoctrineMigrations; | |||
| use Doctrine\DBAL\Schema\Schema; | |||
| use Doctrine\Migrations\AbstractMigration; | |||
| /** | |||
| * Auto-generated Migration: Please modify to your needs! | |||
| */ | |||
| final class Version20240720143743 extends AbstractMigration | |||
| { | |||
| public function getDescription(): string | |||
| { | |||
| return ''; | |||
| } | |||
| public function up(Schema $schema): void | |||
| { | |||
| // this up() migration is auto-generated, please modify it to your needs | |||
| $this->addSql('CREATE TEMPORARY TABLE __temp__project AS SELECT id, name, description FROM project'); | |||
| $this->addSql('DROP TABLE project'); | |||
| $this->addSql('CREATE TABLE project (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, description CLOB DEFAULT NULL, slug VARCHAR(255) NOT NULL)'); | |||
| $this->addSql('INSERT INTO project (id, name, description) SELECT id, name, description FROM __temp__project'); | |||
| $this->addSql('DROP TABLE __temp__project'); | |||
| $this->addSql('CREATE UNIQUE INDEX UNIQ_2FB3D0EE989D9B62 ON project (slug)'); | |||
| } | |||
| public function down(Schema $schema): void | |||
| { | |||
| // this down() migration is auto-generated, please modify it to your needs | |||
| $this->addSql('CREATE TEMPORARY TABLE __temp__project AS SELECT id, name, description FROM project'); | |||
| $this->addSql('DROP TABLE project'); | |||
| $this->addSql('CREATE TABLE project (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, description CLOB DEFAULT NULL)'); | |||
| $this->addSql('INSERT INTO project (id, name, description) SELECT id, name, description FROM __temp__project'); | |||
| $this->addSql('DROP TABLE __temp__project'); | |||
| } | |||
| } | |||
| @ -0,0 +1,327 @@ | |||
| <?php | |||
| namespace App\Controller; | |||
| use App\Entity\Comment; | |||
| use App\Entity\Project; | |||
| use App\Entity\Task; | |||
| use App\Form\CommentType; | |||
| use App\Form\TaskType; | |||
| use App\Service\GeoJsonManager; | |||
| use Doctrine\ORM\EntityManagerInterface; | |||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | |||
| use Symfony\Component\Form\Extension\Core\Type\SubmitType; | |||
| use Symfony\Component\HttpFoundation\JsonResponse; | |||
| use Symfony\Component\HttpFoundation\Request; | |||
| use Symfony\Component\HttpFoundation\Response; | |||
| use Symfony\Component\Routing\Attribute\Route; | |||
| use Symfony\Component\Workflow\WorkflowInterface; | |||
| #[Route('/task')] | |||
| class TaskController extends AbstractController | |||
| { | |||
| #[Route('/{projectSlug}/create', name: 'app_task_create')] | |||
| public function create(Request $request, EntityManagerInterface $entityManager, $projectSlug): Response | |||
| { | |||
| $repository = $entityManager->getRepository(Project::class); | |||
| $project = $repository->findOneBySlug($projectSlug); | |||
| if (!$project) { | |||
| $this->addFlash( | |||
| 'warning', | |||
| 'Projet non trouvé !' | |||
| ); | |||
| return $this->redirectToRoute('app_project'); | |||
| } | |||
| $task = new Task(); | |||
| $task->setProject($project); | |||
| $createForm = $this->createForm(TaskType::class, $task); | |||
| $createForm->add('submit', SubmitType::class, [ | |||
| 'label' => 'Créer', | |||
| ]); | |||
| $createForm->handleRequest($request); | |||
| if ($createForm->isSubmitted() and $createForm->isValid()) { | |||
| $task = $createForm->getData(); | |||
| try { | |||
| $entityManager->persist($task); | |||
| $entityManager->flush(); | |||
| $this->addFlash( | |||
| 'success', | |||
| 'Tâche créée !' | |||
| ); | |||
| return $this->redirectToRoute('app_project_show', ['projectSlug' => $projectSlug]); | |||
| } catch (\Exception $exception) { | |||
| $this->addFlash( | |||
| 'danger', | |||
| 'Impossible de créer la tâche !' | |||
| ); | |||
| } | |||
| } | |||
| return $this->render('task/create.html.twig', [ | |||
| 'project' => $project, | |||
| 'create_form' => $createForm, | |||
| ]); | |||
| } | |||
| #[Route('/{projectSlug}/show/{taskSlug}', name: 'app_task_show')] | |||
| public function show(EntityManagerInterface $entityManager, GeoJsonManager $geoJsonManager, $projectSlug, $taskSlug): Response | |||
| { | |||
| $repository = $entityManager->getRepository(Task::class); | |||
| $task = $repository->findOneBySlug($taskSlug); | |||
| if (!$task) { | |||
| $this->addFlash( | |||
| 'warning', | |||
| 'Tâche non trouvée !' | |||
| ); | |||
| return $this->redirectToRoute('app_project_show', ['projectSlug' => $projectSlug]); | |||
| } | |||
| $comment = new Comment(); | |||
| $commentForm = $this->createForm(CommentType::class, $comment, [ | |||
| 'action' => $this->generateUrl('app_task_comment', ['projectSlug' => $projectSlug, 'taskSlug' => $taskSlug]), | |||
| ]); | |||
| $commentForm->add('submit', SubmitType::class, [ | |||
| 'label' => 'Commenter', | |||
| ]); | |||
| return $this->render('task/show.html.twig', [ | |||
| 'task' => $task, | |||
| 'project' => $task->getProject(), | |||
| 'commentForm' => $commentForm, | |||
| ]); | |||
| } | |||
| #[Route('/{projectSlug}/comment/{taskSlug}', name: 'app_task_comment')] | |||
| public function comment(Request $request, EntityManagerInterface $entityManager, $projectSlug, $taskSlug): Response | |||
| { | |||
| $repository = $entityManager->getRepository(Task::class); | |||
| $task = $repository->findOneBySlug($taskSlug); | |||
| if (!$task) { | |||
| $this->addFlash( | |||
| 'warning', | |||
| 'Tâche non trouvée !' | |||
| ); | |||
| return $this->redirectToRoute('app_project_show', ['projectSlug' => $projectSlug]); | |||
| } | |||
| $comment = new Comment(); | |||
| $comment->setTask($task); | |||
| $commentForm = $this->createForm(CommentType::class, $comment); | |||
| $commentForm->add('submit', SubmitType::class, [ | |||
| 'label' => 'Commenter', | |||
| ]); | |||
| $commentForm->handleRequest($request); | |||
| if ($commentForm->isSubmitted() and $commentForm->isValid()) { | |||
| $comment = $commentForm->getData(); | |||
| try { | |||
| $entityManager->persist($comment); | |||
| $entityManager->flush(); | |||
| $this->addFlash( | |||
| 'success', | |||
| 'Commentaire ajouté !' | |||
| ); | |||
| } catch (\Exception $exception) { | |||
| $this->addFlash( | |||
| 'danger', | |||
| 'Impossible de commenter !' | |||
| ); | |||
| } | |||
| } | |||
| return $this->redirectToRoute('app_task_show', ['projectSlug' => $projectSlug, 'taskSlug' => $taskSlug]); | |||
| } | |||
| #[Route('/{projectSlug}/update/{taskSlug}', name: 'app_task_update')] | |||
| public function update(Request $request, EntityManagerInterface $entityManager, $projectSlug, $taskSlug): Response | |||
| { | |||
| $repository = $entityManager->getRepository(Task::class); | |||
| $task = $repository->findOneBySlug($taskSlug); | |||
| if (!$task) { | |||
| $this->addFlash( | |||
| 'warning', | |||
| 'Tâche non trouvée !' | |||
| ); | |||
| return $this->redirectToRoute('app_project_show', ['projectSlug' => $projectSlug]); | |||
| } | |||
| $updateForm = $this->createForm(TaskType::class, $task); | |||
| $updateForm->add('submit', SubmitType::class, [ | |||
| 'label' => 'Modifier', | |||
| ]); | |||
| $updateForm->handleRequest($request); | |||
| if ($updateForm->isSubmitted() and $updateForm->isValid()) { | |||
| $task = $updateForm->getData(); | |||
| try { | |||
| $entityManager->persist($task); | |||
| $entityManager->flush(); | |||
| $this->addFlash( | |||
| 'success', | |||
| 'Tâche modifiée !' | |||
| ); | |||
| return $this->redirectToRoute('app_project_show', ['projectSlug' => $projectSlug]); | |||
| } catch (\Exception $exception) { | |||
| $this->addFlash( | |||
| 'danger', | |||
| 'Impossible de modifier la tâche !' | |||
| ); | |||
| } | |||
| } | |||
| return $this->render('task/update.html.twig', [ | |||
| 'project' => $task->getProject(), | |||
| 'task' => $task, | |||
| 'update_form' => $updateForm, | |||
| ]); | |||
| } | |||
| #[Route('/{projectSlug}/remove/{taskSlug}', name: 'app_task_remove')] | |||
| public function remove(EntityManagerInterface $entityManager, $projectSlug, $taskSlug): Response | |||
| { | |||
| $repository = $entityManager->getRepository(Task::class); | |||
| $task = $repository->findOneBySlug($taskSlug); | |||
| if (!$task) { | |||
| $this->addFlash( | |||
| 'warning', | |||
| 'Tâche non trouvée !' | |||
| ); | |||
| return $this->redirectToRoute('app_project_show', ['projectSlug' => $projectSlug]); | |||
| } | |||
| try { | |||
| $entityManager->remove($task); | |||
| $entityManager->flush(); | |||
| $this->addFlash( | |||
| 'success', | |||
| 'Tâche supprimée !' | |||
| ); | |||
| return $this->redirectToRoute('app_project_show', ['projectSlug' => $projectSlug]); | |||
| } catch (\Exception $exception) { | |||
| $this->addFlash( | |||
| 'danger', | |||
| 'Impossible de supprimer la tâche !' | |||
| ); | |||
| } | |||
| return $this->redirectToRoute('app_project_show', ['projectSlug' => $projectSlug]); | |||
| } | |||
| private function transition(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $projectSlug, $taskSlug, $status): Response | |||
| { | |||
| $repository = $entityManager->getRepository(Task::class); | |||
| $task = $repository->findOneBySlug($taskSlug); | |||
| if (!$task) { | |||
| $this->addFlash( | |||
| 'warning', | |||
| 'Tâche non trouvée !' | |||
| ); | |||
| return $this->redirectToRoute('app_project_show', ['projectSlug' => $projectSlug]); | |||
| } | |||
| try { | |||
| $taskLifecycleStateMachine->apply($task, $status); | |||
| $entityManager->persist($task); | |||
| $entityManager->flush(); | |||
| $this->addFlash( | |||
| 'success', | |||
| 'La tâche est modifiée !' | |||
| ); | |||
| } catch (Exception $exception) { | |||
| $this->addFlash( | |||
| 'warning', | |||
| 'Impossible de modifier la tâche !' | |||
| ); | |||
| } | |||
| return $this->redirectToRoute('app_task_show', ['projectSlug' => $projectSlug, 'taskSlug' => $taskSlug]); | |||
| } | |||
| #[Route('/{projectSlug}/start/{taskSlug}', name: 'app_task_start')] | |||
| public function start(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $projectSlug, $taskSlug): Response | |||
| { | |||
| return $this->transition($taskLifecycleStateMachine, $entityManager, $projectSlug, $taskSlug, Task::TRANSITION_START); | |||
| } | |||
| #[Route('/{projectSlug}/finish/{taskSlug}', name: 'app_task_finish')] | |||
| public function finish(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $projectSlug, $taskSlug): Response | |||
| { | |||
| return $this->transition($taskLifecycleStateMachine, $entityManager, $projectSlug, $taskSlug, Task::TRANSITION_FINISH); | |||
| } | |||
| #[Route('/{projectSlug}/cancel/{taskSlug}', name: 'app_task_cancel')] | |||
| public function cancel(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $projectSlug, $taskSlug): Response | |||
| { | |||
| return $this->transition($taskLifecycleStateMachine, $entityManager, $projectSlug, $taskSlug, Task::TRANSITION_CANCEL); | |||
| } | |||
| #[Route('/{projectSlug}/reset/{taskSlug}', name: 'app_task_reset')] | |||
| public function reset(WorkflowInterface $taskLifecycleStateMachine, EntityManagerInterface $entityManager, $projectSlug, $taskSlug): Response | |||
| { | |||
| return $this->transition($taskLifecycleStateMachine, $entityManager, $projectSlug, $taskSlug, Task::TRANSITION_RESET); | |||
| } | |||
| #[Route('/{slug}.geojson', name: 'app_task_geojson')] | |||
| public function geojson(Request $request, EntityManagerInterface $entityManager, $slug): Response | |||
| { | |||
| $repository = $entityManager->getRepository(Task::class); | |||
| $task = $repository->findOneBySlug($slug); | |||
| if (!$task) { | |||
| $this->addFlash( | |||
| 'warning', | |||
| 'Tâche non trouvée !' | |||
| ); | |||
| return $this->redirect($request->headers->get('referer')); | |||
| } | |||
| return JsonResponse::fromJsonString($task->getGeojson()); | |||
| } | |||
| #[Route('/{slug}.osm', name: 'app_task_osm')] | |||
| public function osm(Request $request, EntityManagerInterface $entityManager, $slug): Response | |||
| { | |||
| $repository = $entityManager->getRepository(Task::class); | |||
| $task = $repository->findOneBySlug($slug); | |||
| if (!$task) { | |||
| $this->addFlash( | |||
| 'warning', | |||
| 'Tâche non trouvée !' | |||
| ); | |||
| return $this->redirect($request->headers->get('referer')); | |||
| } | |||
| $xml = new \DOMDocument(); | |||
| $xml->loadXml($task->getOsm()); | |||
| $response = new Response($xml->saveXML()); | |||
| $response->headers->set('Content-Type', 'application/xml'); | |||
| return $response; | |||
| } | |||
| } | |||
| @ -0,0 +1,52 @@ | |||
| <?php | |||
| namespace App\Entity; | |||
| use App\Repository\CommentRepository; | |||
| use Doctrine\DBAL\Types\Types; | |||
| use Doctrine\ORM\Mapping as ORM; | |||
| #[ORM\Entity(repositoryClass: CommentRepository::class)] | |||
| class Comment | |||
| { | |||
| #[ORM\Id] | |||
| #[ORM\GeneratedValue] | |||
| #[ORM\Column] | |||
| private ?int $id = null; | |||
| #[ORM\Column(type: Types::TEXT)] | |||
| private ?string $content = null; | |||
| #[ORM\ManyToOne(inversedBy: 'comments')] | |||
| #[ORM\JoinColumn(nullable: false)] | |||
| private ?Task $task = null; | |||
| public function getId(): ?int | |||
| { | |||
| return $this->id; | |||
| } | |||
| public function getContent(): ?string | |||
| { | |||
| return $this->content; | |||
| } | |||
| public function setContent(string $content): static | |||
| { | |||
| $this->content = $content; | |||
| return $this; | |||
| } | |||
| public function getTask(): ?Task | |||
| { | |||
| return $this->task; | |||
| } | |||
| public function setTask(?Task $task): static | |||
| { | |||
| $this->task = $task; | |||
| return $this; | |||
| } | |||
| } | |||
| @ -0,0 +1,75 @@ | |||
| <?php | |||
| namespace App\Entity; | |||
| use App\Repository\TagRepository; | |||
| use Doctrine\Common\Collections\ArrayCollection; | |||
| use Doctrine\Common\Collections\Collection; | |||
| use Doctrine\ORM\Mapping as ORM; | |||
| #[ORM\Entity(repositoryClass: TagRepository::class)] | |||
| class Tag | |||
| { | |||
| #[ORM\Id] | |||
| #[ORM\GeneratedValue] | |||
| #[ORM\Column] | |||
| private ?int $id = null; | |||
| #[ORM\Column(length: 255)] | |||
| private ?string $name = null; | |||
| /** | |||
| * @var Collection<int, Project> | |||
| */ | |||
| #[ORM\ManyToMany(targetEntity: Project::class, mappedBy: 'tags')] | |||
| private Collection $projects; | |||
| public function __construct() | |||
| { | |||
| $this->projects = new ArrayCollection(); | |||
| } | |||
| public function getId(): ?int | |||
| { | |||
| return $this->id; | |||
| } | |||
| public function getName(): ?string | |||
| { | |||
| return $this->name; | |||
| } | |||
| public function setName(string $name): static | |||
| { | |||
| $this->name = $name; | |||
| return $this; | |||
| } | |||
| /** | |||
| * @return Collection<int, Project> | |||
| */ | |||
| public function getProjects(): Collection | |||
| { | |||
| return $this->projects; | |||
| } | |||
| public function addProject(Project $project): static | |||
| { | |||
| if (!$this->projects->contains($project)) { | |||
| $this->projects->add($project); | |||
| $project->addTag($this); | |||
| } | |||
| return $this; | |||
| } | |||
| public function removeProject(Project $project): static | |||
| { | |||
| if ($this->projects->removeElement($project)) { | |||
| $project->removeTag($this); | |||
| } | |||
| return $this; | |||
| } | |||
| } | |||
| @ -0,0 +1,204 @@ | |||
| <?php | |||
| namespace App\Entity; | |||
| use App\Repository\TaskRepository; | |||
| use Doctrine\Common\Collections\ArrayCollection; | |||
| use Doctrine\Common\Collections\Collection; | |||
| use Doctrine\DBAL\Types\Types; | |||
| use Doctrine\ORM\Mapping as ORM; | |||
| use Gedmo\Mapping\Annotation as Gedmo; | |||
| #[ORM\Entity(repositoryClass: TaskRepository::class)] | |||
| class Task | |||
| { | |||
| const STATUS_TODO = 'todo'; | |||
| const STATUS_DOING = 'doing'; | |||
| const STATUS_DONE = 'done'; | |||
| const TRANSITION_START = 'start'; | |||
| const TRANSITION_FINISH = 'finish'; | |||
| const TRANSITION_CANCEL = 'cancel'; | |||
| const TRANSITION_RESET = 'reset'; | |||
| #[ORM\Id] | |||
| #[ORM\GeneratedValue] | |||
| #[ORM\Column] | |||
| private ?int $id = null; | |||
| #[ORM\Column(length: 255)] | |||
| private ?string $name = null; | |||
| #[ORM\Column(length: 255, unique: true)] | |||
| #[Gedmo\Slug(fields: ['name'])] | |||
| private ?string $slug = null; | |||
| #[ORM\Column(nullable: true)] | |||
| private ?int $urgent = null; | |||
| #[ORM\Column(nullable: true)] | |||
| private ?int $important = null; | |||
| #[ORM\Column(length: 255)] | |||
| private ?string $status = null; | |||
| #[ORM\ManyToOne(inversedBy: 'tasks')] | |||
| #[ORM\JoinColumn(nullable: false)] | |||
| private ?Project $project = null; | |||
| #[ORM\Column(type: Types::TEXT)] | |||
| private ?string $geojson = null; | |||
| #[ORM\Column(type: Types::TEXT)] | |||
| private ?string $osm = null; | |||
| /** | |||
| * @var Collection<int, Comment> | |||
| */ | |||
| #[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'task', orphanRemoval: true)] | |||
| private Collection $comments; | |||
| #[ORM\Column(type: Types::TEXT, nullable: true)] | |||
| private ?string $description = null; | |||
| public function __construct() | |||
| { | |||
| $this->comments = new ArrayCollection(); | |||
| } | |||
| public function getId(): ?int | |||
| { | |||
| return $this->id; | |||
| } | |||
| public function getName(): ?string | |||
| { | |||
| return $this->name; | |||
| } | |||
| public function setName(string $name): static | |||
| { | |||
| $this->name = $name; | |||
| return $this; | |||
| } | |||
| public function getSlug(): ?string | |||
| { | |||
| return $this->slug; | |||
| } | |||
| public function getUrgent(): ?int | |||
| { | |||
| return $this->urgent; | |||
| } | |||
| public function setUrgent(?int $urgent): static | |||
| { | |||
| $this->urgent = $urgent; | |||
| return $this; | |||
| } | |||
| public function getImportant(): ?int | |||
| { | |||
| return $this->important; | |||
| } | |||
| public function setImportant(?int $important): static | |||
| { | |||
| $this->important = $important; | |||
| return $this; | |||
| } | |||
| public function getStatus(): ?string | |||
| { | |||
| return $this->status; | |||
| } | |||
| public function setStatus($status, array $context = []): static | |||
| { | |||
| $this->status = $status; | |||
| return $this; | |||
| } | |||
| public function getProject(): ?Project | |||
| { | |||
| return $this->project; | |||
| } | |||
| public function setProject(?Project $project): static | |||
| { | |||
| $this->project = $project; | |||
| return $this; | |||
| } | |||
| public function getGeojson(): ?string | |||
| { | |||
| return $this->geojson; | |||
| } | |||
| public function setGeojson(string $geojson): static | |||
| { | |||
| $this->geojson = $geojson; | |||
| return $this; | |||
| } | |||
| public function getOsm(): ?string | |||
| { | |||
| return $this->osm; | |||
| } | |||
| public function setOsm(string $osm): static | |||
| { | |||
| $this->osm = $osm; | |||
| return $this; | |||
| } | |||
| /** | |||
| * @return Collection<int, Comment> | |||
| */ | |||
| public function getComments(): Collection | |||
| { | |||
| return $this->comments; | |||
| } | |||
| public function addComment(Comment $comment): static | |||
| { | |||
| if (!$this->comments->contains($comment)) { | |||
| $this->comments->add($comment); | |||
| $comment->setTask($this); | |||
| } | |||
| return $this; | |||
| } | |||
| public function removeComment(Comment $comment): static | |||
| { | |||
| if ($this->comments->removeElement($comment)) { | |||
| // set the owning side to null (unless already changed) | |||
| if ($comment->getTask() === $this) { | |||
| $comment->setTask(null); | |||
| } | |||
| } | |||
| return $this; | |||
| } | |||
| public function getDescription(): ?string | |||
| { | |||
| return $this->description; | |||
| } | |||
| public function setDescription(?string $description): static | |||
| { | |||
| $this->description = $description; | |||
| return $this; | |||
| } | |||
| } | |||
| @ -0,0 +1,27 @@ | |||
| <?php | |||
| namespace App\Form; | |||
| use App\Entity\Comment; | |||
| use App\Entity\Task; | |||
| use Symfony\Bridge\Doctrine\Form\Type\EntityType; | |||
| use Symfony\Component\Form\AbstractType; | |||
| use Symfony\Component\Form\FormBuilderInterface; | |||
| use Symfony\Component\OptionsResolver\OptionsResolver; | |||
| class CommentType extends AbstractType | |||
| { | |||
| public function buildForm(FormBuilderInterface $builder, array $options): void | |||
| { | |||
| $builder | |||
| ->add('content', null, ['label' => false]) | |||
| ; | |||
| } | |||
| public function configureOptions(OptionsResolver $resolver): void | |||
| { | |||
| $resolver->setDefaults([ | |||
| 'data_class' => Comment::class, | |||
| ]); | |||
| } | |||
| } | |||
| @ -0,0 +1,42 @@ | |||
| <?php | |||
| namespace App\Form; | |||
| use App\Entity\Task; | |||
| use Symfony\Component\Form\AbstractType; | |||
| use Symfony\Component\Form\Extension\Core\Type\ChoiceType; | |||
| use Symfony\Component\OptionsResolver\OptionsResolver; | |||
| use Symfony\Component\Workflow\WorkflowInterface; | |||
| use Symfony\Component\DependencyInjection\Attribute\Target; | |||
| class TaskLifecycleType extends AbstractType | |||
| { | |||
| public function __construct( | |||
| private WorkflowInterface $taskLifecycleStateMachine, | |||
| ) { | |||
| } | |||
| public function configureOptions(OptionsResolver $resolver): void | |||
| { | |||
| $choices = []; | |||
| $places = $this->taskLifecycleStateMachine->getDefinition()->getPlaces(); | |||
| $metas = $this->taskLifecycleStateMachine->getMetadataStore(); | |||
| foreach ($places as $place => $id) { | |||
| $meta = $metas->getPlaceMetadata($place); | |||
| if (isset($meta['title'])) { | |||
| $choices[$meta['title']] = $place; | |||
| } | |||
| } | |||
| $resolver->setDefaults([ | |||
| 'choices' => $choices, | |||
| ]); | |||
| } | |||
| public function getParent(): string | |||
| { | |||
| return ChoiceType::class; | |||
| } | |||
| } | |||
| @ -0,0 +1,33 @@ | |||
| <?php | |||
| namespace App\Form; | |||
| use App\Entity\Task; | |||
| use App\Form\TaskLifecycleType; | |||
| use Symfony\Component\Form\AbstractType; | |||
| use Symfony\Component\Form\Extension\Core\Type\TextareaType; | |||
| use Symfony\Component\Form\FormBuilderInterface; | |||
| use Symfony\Component\OptionsResolver\OptionsResolver; | |||
| class TaskType extends AbstractType | |||
| { | |||
| public function buildForm(FormBuilderInterface $builder, array $options): void | |||
| { | |||
| $builder | |||
| ->add('name', null, ['label' => 'Nom']) | |||
| ->add('description', null, ['label' => 'Description']) | |||
| ->add('geojson', TextareaType::class, ['label' => 'GeoJSON']) | |||
| ->add('osm', TextareaType::class, ['label' => 'OSM']) | |||
| ->add('status', TaskLifecycleType::class, ['label' => 'État']) | |||
| ->add('urgent', null, ['label' => 'Urgence']) | |||
| ->add('important', null, ['label' => 'Importance']) | |||
| ; | |||
| } | |||
| public function configureOptions(OptionsResolver $resolver): void | |||
| { | |||
| $resolver->setDefaults([ | |||
| 'data_class' => Task::class, | |||
| ]); | |||
| } | |||
| } | |||
| @ -0,0 +1,43 @@ | |||
| <?php | |||
| namespace App\Repository; | |||
| use App\Entity\Comment; | |||
| use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; | |||
| use Doctrine\Persistence\ManagerRegistry; | |||
| /** | |||
| * @extends ServiceEntityRepository<Comment> | |||
| */ | |||
| class CommentRepository extends ServiceEntityRepository | |||
| { | |||
| public function __construct(ManagerRegistry $registry) | |||
| { | |||
| parent::__construct($registry, Comment::class); | |||
| } | |||
| // /** | |||
| // * @return Comment[] Returns an array of Comment objects | |||
| // */ | |||
| // public function findByExampleField($value): array | |||
| // { | |||
| // return $this->createQueryBuilder('c') | |||
| // ->andWhere('c.exampleField = :val') | |||
| // ->setParameter('val', $value) | |||
| // ->orderBy('c.id', 'ASC') | |||
| // ->setMaxResults(10) | |||
| // ->getQuery() | |||
| // ->getResult() | |||
| // ; | |||
| // } | |||
| // public function findOneBySomeField($value): ?Comment | |||
| // { | |||
| // return $this->createQueryBuilder('c') | |||
| // ->andWhere('c.exampleField = :val') | |||
| // ->setParameter('val', $value) | |||
| // ->getQuery() | |||
| // ->getOneOrNullResult() | |||
| // ; | |||
| // } | |||
| } | |||
| @ -0,0 +1,43 @@ | |||
| <?php | |||
| namespace App\Repository; | |||
| use App\Entity\Tag; | |||
| use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; | |||
| use Doctrine\Persistence\ManagerRegistry; | |||
| /** | |||
| * @extends ServiceEntityRepository<Tag> | |||
| */ | |||
| class TagRepository extends ServiceEntityRepository | |||
| { | |||
| public function __construct(ManagerRegistry $registry) | |||
| { | |||
| parent::__construct($registry, Tag::class); | |||
| } | |||
| // /** | |||
| // * @return Tag[] Returns an array of Tag objects | |||
| // */ | |||
| // public function findByExampleField($value): array | |||
| // { | |||
| // return $this->createQueryBuilder('t') | |||
| // ->andWhere('t.exampleField = :val') | |||
| // ->setParameter('val', $value) | |||
| // ->orderBy('t.id', 'ASC') | |||
| // ->setMaxResults(10) | |||
| // ->getQuery() | |||
| // ->getResult() | |||
| // ; | |||
| // } | |||
| // public function findOneBySomeField($value): ?Tag | |||
| // { | |||
| // return $this->createQueryBuilder('t') | |||
| // ->andWhere('t.exampleField = :val') | |||
| // ->setParameter('val', $value) | |||
| // ->getQuery() | |||
| // ->getOneOrNullResult() | |||
| // ; | |||
| // } | |||
| } | |||
| @ -0,0 +1,43 @@ | |||
| <?php | |||
| namespace App\Repository; | |||
| use App\Entity\Task; | |||
| use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; | |||
| use Doctrine\Persistence\ManagerRegistry; | |||
| /** | |||
| * @extends ServiceEntityRepository<Task> | |||
| */ | |||
| class TaskRepository extends ServiceEntityRepository | |||
| { | |||
| public function __construct(ManagerRegistry $registry) | |||
| { | |||
| parent::__construct($registry, Task::class); | |||
| } | |||
| // /** | |||
| // * @return Task[] Returns an array of Task objects | |||
| // */ | |||
| // public function findByExampleField($value): array | |||
| // { | |||
| // return $this->createQueryBuilder('t') | |||
| // ->andWhere('t.exampleField = :val') | |||
| // ->setParameter('val', $value) | |||
| // ->orderBy('t.id', 'ASC') | |||
| // ->setMaxResults(10) | |||
| // ->getQuery() | |||
| // ->getResult() | |||
| // ; | |||
| // } | |||
| // public function findOneBySomeField($value): ?Task | |||
| // { | |||
| // return $this->createQueryBuilder('t') | |||
| // ->andWhere('t.exampleField = :val') | |||
| // ->setParameter('val', $value) | |||
| // ->getQuery() | |||
| // ->getOneOrNullResult() | |||
| // ; | |||
| // } | |||
| } | |||
| @ -0,0 +1,48 @@ | |||
| <?php | |||
| namespace App\Service; | |||
| use App\Entity\Project; | |||
| use App\Entity\Task; | |||
| use GeoJson\GeoJson; | |||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | |||
| use Symfony\Component\Workflow\WorkflowInterface; | |||
| class GeoJsonManager | |||
| { | |||
| public function __construct( | |||
| private UrlGeneratorInterface $router, | |||
| private WorkflowInterface $taskLifecycleStateMachine, | |||
| ) { | |||
| } | |||
| private function getFullGeoJson(Task $task) | |||
| { | |||
| $data = json_decode($task->getGeojson(), true); | |||
| $data['features'][0]['properties'] = array_merge($data['features'][0]['properties'], [ | |||
| 'name' => $task->getName(), | |||
| 'url' => $this->router->generate('app_task_show', ['projectSlug' => $task->getProject()->getSlug(), 'taskSlug' => $task->getSlug()], UrlGeneratorInterface::ABSOLUTE_URL), | |||
| 'color' => $this->taskLifecycleStateMachine->getMetadataStore()->getPlaceMetadata($task->getStatus())['color'], | |||
| ]); | |||
| return $data; | |||
| } | |||
| public function generateGeoJson($entity) | |||
| { | |||
| $geoJsons = []; | |||
| if ($entity instanceof Task) { | |||
| $task = $entity; | |||
| $geoJsons[] = GeoJson::jsonUnserialize($this->getFullGeojson($task)); | |||
| } elseif ($entity instanceof Project) { | |||
| $project = $entity; | |||
| foreach ($project->getTasks() as $task) { | |||
| $geoJsons[] = GeoJson::jsonUnserialize($this->getFullGeojson($task)); | |||
| } | |||
| } | |||
| return $geoJsons; | |||
| } | |||
| } | |||
| @ -0,0 +1,55 @@ | |||
| <?php | |||
| namespace App\Service; | |||
| use App\Entity\Project; | |||
| use Symfony\Component\Workflow\WorkflowInterface; | |||
| class TaskLifecycleManager | |||
| { | |||
| public function __construct( | |||
| private WorkflowInterface $taskLifecycleStateMachine, | |||
| ) { | |||
| } | |||
| public function getProjectStats(Project $project) | |||
| { | |||
| $stats = []; | |||
| $places = $this->taskLifecycleStateMachine->getDefinition()->getPlaces(); | |||
| $metas = $this->taskLifecycleStateMachine->getMetadataStore(); | |||
| if (empty($places)) { | |||
| return $stats; | |||
| } | |||
| foreach ($places as $place => $id) { | |||
| $stats[$place] = array_merge($metas->getPlaceMetadata($place), [ | |||
| 'value' => 0, | |||
| ]); | |||
| } | |||
| $max = 0; | |||
| foreach ($project->getTasks() as $task) | |||
| { | |||
| $stats[$task->getStatus()]['value'] += 1; | |||
| $max += 1; | |||
| } | |||
| if ($max === 0) { | |||
| return $stats; | |||
| } | |||
| $stats = array_map(function ($data) use ($max) { | |||
| $data['max'] = $max; | |||
| $data['percentage'] = ((float) $data['value'] * 100.0) / $max; | |||
| return $data; | |||
| }, $stats); | |||
| return $stats; | |||
| } | |||
| } | |||
| @ -0,0 +1,13 @@ | |||
| {% extends 'base.html.twig' %} | |||
| {% block breadcrumb %} | |||
| <li class="breadcrumb-item"><a href="{{ path('app_project') }}">Projets</a></li> | |||
| <li class="breadcrumb-item"><a href="{{ path('app_project_show', {'projectSlug': project.slug}) }}">Projet {{ project.name }}</a></li> | |||
| <li class="breadcrumb-item"><a href="{{ path('app_task_create', {'projectSlug': project.slug}) }}">Créer une nouvelle tâche</a></li> | |||
| {% endblock %} | |||
| {% block page_title %}Créer une nouvelle tâche{% endblock %} | |||
| {% block page_content %} | |||
| {{ form(create_form) }} | |||
| {% endblock %} | |||
| @ -0,0 +1,60 @@ | |||
| {% extends 'base.html.twig' %} | |||
| {% block breadcrumb %} | |||
| <li class="breadcrumb-item"><a href="{{ path('app_project') }}">Projets</a></li> | |||
| <li class="breadcrumb-item"><a href="{{ path('app_project_show', {'projectSlug': project.slug}) }}">Projet {{ project.name }}</a></li> | |||
| <li class="breadcrumb-item"><a href="{{ path('app_task_show', {'projectSlug': project.slug, 'taskSlug': task.slug}) }}">Tâche {{ task.name }}</a></li> | |||
| {% endblock %} | |||
| {% block page_title %} | |||
| {{ task.name }} | |||
| <span class="badge {{ 'text-bg-' ~ workflow_metadata(task, 'color', task.status) }} ms-2">{{ workflow_metadata(task, 'title', task.status) }}</span> | |||
| {% endblock %} | |||
| {% block page_content %} | |||
| <div class="row"> | |||
| <div class="col mb-3"> | |||
| <a href="{{ path('app_project_show', {'projectSlug': project.slug}) }}" class="btn btn-primary">Revenir au projet</a> | |||
| <a href="{{ path('app_task_update', {'projectSlug': project.slug, 'taskSlug': task.slug}) }}" class="btn btn-primary">Modifier la tâche</a> | |||
| <a href="{{ path('app_task_remove', {'projectSlug': project.slug, 'taskSlug': task.slug}) }}" target="_blank" class="btn btn-primary">Supprimer la tâche</a> | |||
| <a href="{{ path('app_task_geojson', {'slug': task.slug}) }}" target="_blank" class="btn btn-primary">Télécharger GeoJSON</a> | |||
| {% for transition in workflow_transitions(task) %} | |||
| <a href="{{ path(workflow_metadata(task, 'route', transition), {'projectSlug': project.slug, 'taskSlug': task.slug}) }}" class="btn btn-primary">{{ workflow_metadata(task, 'title', transition) }}</a> | |||
| {% endfor %} | |||
| <button class="btn btn-primary" type="button" data-controller="josm" data-action="click->josm#remoteControl" data-josm-importurl-value="{{ url('app_task_osm', {'slug': task.slug}) }}" data-josm-layername-value="{{ task.name }}">Télécommande JOSM</button> | |||
| </div> | |||
| </div> | |||
| {% if task.description is not empty %} | |||
| <h2 class="mb-3">Description</h2> | |||
| <div class="row"> | |||
| <div class="col mb-3 lead">{{ task.description|markdown_to_html }}</div> | |||
| </div> | |||
| {% endif %} | |||
| <h2 class="mb-3">Carte</h2> | |||
| <div class="row"> | |||
| <div class="col mb-3"> | |||
| <div id="map" class="img-fluid img-thumbnail min-vh-50" data-controller="map" data-map-geojson-value="{{ geoJsonManager.generateGeoJson(task)|json_encode }}"></div> | |||
| </div> | |||
| </div> | |||
| <h2 class="mb-3">Commentaires</h2> | |||
| {% if task.comments is not empty %} | |||
| <div class="row"> | |||
| <div class="col mb-3"> | |||
| {% for comment in task.comments %} | |||
| <blockquote class="blockquote"> | |||
| {{ comment.content|markdown_to_html }} | |||
| </blockquote> | |||
| {% endfor %} | |||
| </div> | |||
| </div> | |||
| {% endif %} | |||
| <div class="row"> | |||
| <div class="col mb-3"> | |||
| {{ form(commentForm) }} | |||
| </div> | |||
| </div> | |||
| {% endblock %} | |||
| @ -0,0 +1,13 @@ | |||
| {% extends 'base.html.twig' %} | |||
| {% block breadcrumb %} | |||
| <li class="breadcrumb-item"><a href="{{ path('app_project') }}">Projets</a></li> | |||
| <li class="breadcrumb-item"><a href="{{ path('app_project_show', {'projectSlug': project.slug}) }}">Projet {{ project.name }}</a></li> | |||
| <li class="breadcrumb-item"><a href="{{ path('app_task_show', {'projectSlug': project.slug, 'taskSlug': task.slug}) }}">Tâche {{ task.name }}</a></li> | |||
| {% endblock %} | |||
| {% block page_title %}Modifier la tâche {{ task.name }}{% endblock %} | |||
| {% block page_content %} | |||
| {{ form(update_form) }} | |||
| {% endblock %} | |||