@ -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 './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 %} |