@ -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 %} |