Merge branch 'macroplanning' into develop

This commit is contained in:
Jérémy Munsch 2022-05-09 10:11:44 +02:00
commit f4412728d6
No known key found for this signature in database
GPG key ID: 18361AF3BFEC0FEC
50 changed files with 4035 additions and 1667 deletions

17
.vscode/settings.json vendored
View file

@ -1,16 +1,16 @@
{
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#275782",
"activityBar.activeBorder": "#1b1e21",
"activityBar.background": "#275782",
"activityBar.activeBackground": "#3371a9",
"activityBar.activeBorder": "#621e41",
"activityBar.background": "#3371a9",
"activityBar.foreground": "#e7e7e7",
"activityBar.inactiveForeground": "#e7e7e799",
"activityBarBadge.background": "#1b1e21",
"activityBarBadge.foreground": "#ffffff",
"sash.hoverBorder": "#275782",
"activityBarBadge.background": "#621e41",
"activityBarBadge.foreground": "#e7e7e7",
"sash.hoverBorder": "#3371a9",
"statusBar.background": "#275782",
"statusBar.foreground": "#e7e7e7",
"statusBarItem.hoverBackground": "#275782",
"statusBarItem.hoverBackground": "#3371a9",
"statusBarItem.remoteBackground": "#275782",
"statusBarItem.remoteForeground": "#e7e7e7",
"titleBar.activeBackground": "#275782",
@ -18,4 +18,5 @@
"titleBar.inactiveBackground": "#27578299",
"titleBar.inactiveForeground": "#e7e7e799"
},
}
"peacock.remoteColor": "#275782",
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Enums\MacroSchedule;
use Kwaadpepper\Enum\BaseEnum;
/**
* @method static self conception()
* @method static self development()
* @method static self recipe()
* @method static self production()
* @method static self meeting()
*/
final class Activity extends BaseEnum
{
/**
* Enum values
*
* @return array
*/
protected static function values(): array
{
return [
'conception' => 0,
'development' => 1,
'recipe' => 2,
'production' => 3,
'meeting' => 4
];
}
/**
* Enum labels
*
* @return array
*/
protected static function labels(): array
{
return [
'conception' => trans('Conception / Rédaction'),
'development' => trans('Développement'),
'recipe' => trans('Mise en recette'),
'production' => trans('Mise en production'),
'meeting' => trans('Réunion')
];
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace App\Enums;
use Kwaadpepper\Enum\BaseEnum;
/**
* @method static self new()
* @method static self specification()
* @method static self validated()
* @method static self development()
* @method static self recipe()
* @method static self production()
* @method static self finished()
*/
final class ProjectStatus extends BaseEnum
{
/**
* Enum values
*
* @return array
*/
protected static function values(): array
{
return [
'new' => 0,
'specification' => 1,
'validated' => 2,
'development' => 3,
'recipe' => 4,
'production' => 5,
'finished' => 6
];
}
/**
* Enum labels
*
* @return array
*/
protected static function labels(): array
{
return [
'new' => trans('Nouveau'),
'specification' => trans('Spécifications'),
'validated' => trans('Validé'),
'development' => trans('En développement'),
'recipe' => trans('En recettage'),
'production' => trans('En production'),
'finished' => trans('Terminé')
];
}
}

View file

@ -17,22 +17,25 @@ class Controller extends BaseController
use DispatchesJobs;
use ValidatesRequests;
/**
* Controller
*/
public function __construct()
{
$this->middleware(function($request, $next) {
$this->middleware(function ($request, $next) {
$ignore = [
// Support Web
// * Support Web
9,
// Gen-Game- Web
// * Gen-Game- Web
15,
// Store LDC
// * Store LDC
19,
// CRM
// * CRM
27,
// LDC - WEB
// * LDC - WEB
55,
];
$ticketsNew = Ticket::where('status', TicketStatus::new());
$ticketsWaiting = Ticket::where('status', TicketStatus::waiting());
$ticketsInProgress = Ticket::whereNotIn(
@ -48,19 +51,19 @@ class Controller extends BaseController
TicketStatus::archived()
]
);
/** @var \App\Models\User|null $user */
/** @var \App\Models\User|null */
$user = Auth::user();
if (!is_null($user) and !$user->isAdmin()) {
$ticketsNew = $ticketsNew->where('is_internal', 0)->whereNotIn('site_id', $ignore);
$ticketsWaiting = $ticketsWaiting->where('is_internal', 0)->whereNotIn('site_id', $ignore);
$ticketsInProgress = $ticketsInProgress->where('is_internal', 0)->whereNotIn('site_id', $ignore);
}
View::share('ticketsNew', $ticketsNew->count());
View::share('ticketsWaiting', $ticketsWaiting->count());
View::share('ticketsInProgress', $ticketsInProgress->count());
return $next($request);
});
}

View file

@ -17,8 +17,19 @@ class FileController extends Controller
/** @var File */
public static $modelClass = File::class;
/**
* Crud Layout
*
* @var string
*/
public static $layout = 'layout.app';
/**
* Index query middleware
*
* @param Builder $query
* @return Builder
*/
public function indexQuery(Builder $query): Builder
{
$parent = request()->site ?? request()->project;
@ -30,6 +41,12 @@ class FileController extends Controller
return $query;
}
/**
* Crud index view
*
* @param \Kwaadpepper\CrudPolicies\Http\Requests\CrudIndexRequest $request
* @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
*/
public function index(CrudIndexRequest $request)
{
/** @var \Illuminate\View\View */
@ -40,6 +57,12 @@ class FileController extends Controller
return view('files.index', $view->getData());
}
/**
* Crud create view
*
* @param \Kwaadpepper\CrudPolicies\Http\Requests\CrudIndexRequest $request
* @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
*/
public function create(CrudIndexRequest $request)
{
/** @var \Illuminate\View\View */
@ -47,6 +70,12 @@ class FileController extends Controller
return view('files.create', $view->getData());
}
/**
* Before store model middleware
*
* @param \App\Models\File $file
* @return void
*/
public function storeModel(File &$file)
{
$file->parent()->associate(request()->site ?? request()->project);

View file

@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers;
use App\Models\MacroSchedule\Marker;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\View;
use Kwaadpepper\CrudPolicies\Traits\CrudController;
class MarkerController extends Controller
{
use CrudController;
/** @var \App\Models\MacroSchedule\Marker */
public static $modelClass = Marker::class;
/**
* Crud Layout
*
* @var string
*/
public static $layout = 'layout.app';
/**
* Show create page middleware
*
* @return void
*/
public function createModel()
{
$this->shareLabelsToView();
}
/**
* Show edit model middleware
*
* @param Model $model
* @return void
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
*/
public function editModel(Model &$model)
{
$this->shareLabelsToView();
}
/**
* Before store model middleware
*
* @param \App\Models\MacroSchedule\Marker $marker
* @return void
*/
public function storeModel(Marker &$marker)
{
$marker->project()->associate(request()->project);
}
/**
* Share Marker labels to view
*
* @return void
*/
protected function shareLabelsToView()
{
View::share('markerLabels', Cache::remember('markers_labels', 20, function () {
return Marker::pluck('label');
}));
}
}

View file

@ -10,13 +10,15 @@ use Kwaadpepper\CrudPolicies\Traits\CrudController;
class ProjectController extends Controller
{
use CrudController {
__construct as __CrudConstructor;
index as crudIndex;
show as crudShow;
}
/**
* ProjectController
*/
public function __construct()
{
parent::__construct();
@ -24,11 +26,22 @@ class ProjectController extends Controller
$this->authorizeResource(Project::class, 'project');
}
/** @var Project */
/** @var \App\Models\Project */
public static $modelClass = Project::class;
/**
* Crud Layout
*
* @var string
*/
public static $layout = 'layout.app';
/**
* Crud index view
*
* @param \Kwaadpepper\CrudPolicies\Http\Requests\CrudIndexRequest $request
* @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
*/
public function index(CrudIndexRequest $request)
{
/** @var \Illuminate\View\View */
@ -39,6 +52,13 @@ class ProjectController extends Controller
return view('projects.index', $view->getData());
}
/**
* Crud show view
*
* @param \Illuminate\Http\Request $request
* @param \App\Models\Project $model
* @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
*/
public function show(Request $request, Project $model)
{
/** @var \Illuminate\View\View */

View file

@ -0,0 +1,120 @@
<?php
namespace App\Http\Controllers;
use App\Enums\ProjectStatus;
use App\Http\Requests\MacroSchedule\PostActivityRequest;
use App\Http\Requests\MacroSchedule\ShowMacroScheduleRequest;
use App\Models\MacroSchedule\Activity;
use App\Models\Project;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class ScheduleMacroController extends Controller
{
/**
* View Macro planning
*
* @param \App\Http\Requests\MacroSchedule\ShowMacroScheduleRequest $request
* @return \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
*/
public function show(ShowMacroScheduleRequest $request)
{
$numberOfWeeksToShow = \config('macroschedule.numberOfWeeksToShow');
$date = Carbon::now();
if ($request->date) {
$date = Carbon::createFromFormat('d/m/Y', $request->date);
}
/** @var \Illuminate\Support\Carbon $date The date from which to take activities */
$date->startOfWeek();
/** @var \Illuminate\Support\Collection $projects */
$projects = Project::where('status', '>', ProjectStatus::new())
->where('status', '<', ProjectStatus::finished())
->with(['activities' => function ($q) use ($date, $numberOfWeeksToShow) {
$q->whereDate('start', '>=', $date->toDateString())
->OrWhereDate('end', '<=', $date->clone()->addWeeks($numberOfWeeksToShow)->toDateString());
}])->with('markers')->orderBy('status')->orderBy('name')->get()->keyBy('id');
// * Key by date activities relation
$projects->map(function (Project $project) {
$project->setRelation('activities', $project->activities->keyBy('id'));
});
// * Map activities by datetime
$activities = collect();
$projects->pluck('activities')->map(function (Collection $projectActivities) use (&$activities) {
$projectActivities->map(function (Activity $activity) use (&$activities) {
$activities->push($activity);
});
});
// * Disponibility calculation
$loadCollection = collect();
/** @var array<\Illuminate\Support\Carbon> */
$period = $date->toPeriod($date->clone()->addWeeks($numberOfWeeksToShow), 12, 'hour')->toArray();
foreach ($period as $day) {
/** @var \Illuminate\Support\Collection */
$dayActivities = $activities->filter(function (Activity $activity) use ($day) {
return $day->betweenIncluded($activity->start, $activity->end);
});
/** @var integer total activités / hommes / demi journées */
$totalLoad = $dayActivities->sum('load');
$loadCollection->put($day->format('Y-m-d H:i:s'), $totalLoad / config('macroschedule.totalAvailableDevs'));
}
return view('macroplanning.view', compact(
'date',
'projects',
'date',
'numberOfWeeksToShow',
'loadCollection'
));
}
/**
* Save activity.
*
* @param PostActivityRequest $request
* @return \Illuminate\Http\JsonResponse
*/
public function postActivity(PostActivityRequest $request)
{
return DB::transaction(function () use ($request) {
$hasOverLap = false;
/** @var \App\Models\Project */
$project = Project::whereId($request->project_id)
->with('activities')->firstOrFail();
/** @var \App\Models\MacroSchedule\Activity */
$activity = Activity::whereId($request->id)->firstOrNew();
$activity->fill($request->validated());
$activity->project()->associate($project);
if (!$activity->saveOrFail()) {
response()->json('Une erreur est survenue lors de \'enregistrement');
}
// * Overload activities if necessary
$project->activities->map(function (Activity $periodActivity) use ($activity, &$hasOverLap) {
if (
$activity->id !== $periodActivity->id and
$activity->doesPeriodCoverActivity($periodActivity->start, $periodActivity->end)
) {
$hasOverLap = true;
$periodActivity->overlapUsingActivity($activity);
}
});
return response()->json([
'id' => $activity->id,
'hasOverlap' => $hasOverLap
]);
});
}
}

View file

@ -0,0 +1,105 @@
<?php
namespace App\Http\Requests\MacroSchedule;
use App\Enums\MacroSchedule\Activity as EnumActivity;
use App\Models\MacroSchedule\Activity as ModelActivity;
use App\Models\Project;
use Carbon\Exceptions\InvalidFormatException;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Kwaadpepper\Enum\Rules\EnumIsValidRule;
class PostActivityRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize()
{
return auth()->check() and $this->ajax();
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
/** @var \App\Models\Project|null $project */
$project = Project::whereId($this->project_id)->with('activities')->first();
if ($project and $this->id) {
$project->setRelation(
'activities',
$project->activities->filter(function (ModelActivity $activity) {
return $activity->id !== $this->id;
})
);
}
return [
'project_id' => 'required|exists:projects,id',
'id' => 'nullable|exists:macro_activities,id',
'activity' => ['required', new EnumIsValidRule(EnumActivity::class)],
'comment' => 'required|min:0|max:255',
'start' => [
'required',
'date_format:Y-m-d\TH:i:s',
function ($attribute, $value, $fail) use ($project) {
try {
$dateTime = Carbon::parse($value);
if (in_array($dateTime->dayOfWeek, [0, 6])) {
$fail('La date de début ne peut être un jour de weekend.');
return;
}
if (!in_array($dateTime->format('H:i'), ['00:00', '12:00'])) {
// phpcs:ignore Generic.Files.LineLength.TooLong
$fail("$attribute : Il est seulement possible de définir une date le matin ou l'après midi.");
}
} catch (InvalidFormatException $e) {
$fail('Format de date invalide');
}
}
],
'end' => [
'required',
'date_format:Y-m-d\TH:i:s',
'after:start',
function ($attribute, $value, $fail) use ($project) {
try {
$dateTime = Carbon::parse($value);
if (in_array($dateTime->dayOfWeek, [0, 6])) {
$fail('La date de fin ne peut être un jour de weekend.');
return;
}
if (!in_array($dateTime->format('H:i'), ['11:59', '23:59'])) {
// phpcs:ignore Generic.Files.LineLength.TooLong
$fail("$attribute : Il est seulement possible de définir une date le matin ou l'après midi.");
}
} catch (InvalidFormatException $e) {
$fail('Format de date invalide');
}
}
],
'load' => 'required|integer|min:1|max:' . config('macroschedule.totalAvailableDevs')
];
}
/**
* Get custom attributes for validator errors.
*
* @return array
*/
public function attributes(): array
{
return [
'start' => 'Date de début',
'end' => 'Date de fin',
'activity' => 'Activité',
'comment' => 'Commentaire',
'load' => 'Charge',
];
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\MacroSchedule;
use Illuminate\Foundation\Http\FormRequest;
class ShowMacroScheduleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize()
{
return auth()->check();
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'date' => 'nullable|date_format:d/m/Y',
];
}
}

View file

@ -7,10 +7,16 @@ use Illuminate\Support\Facades\View;
class Utils
{
public static function plainToHtml(string $text)
/**
* Compute plain text to html representation.
*
* @param string $text
* @return string
*/
public static function plainToHtml(string $text): string
{
$text = e($text);
// url replace and email replace
// * url replace and email replace
$text = preg_replace_callback(
// phpcs:ignore Generic.Files.LineLength.TooLong
'/(?P<email>[^\s@]+@([^\s@.,]+\.)+[^\s@.,]{2,})|(?P<user>@\w*)|(?P<url>(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*))/',
@ -41,6 +47,12 @@ class Utils
return nl2br($text);
}
/**
* Check uri points to image file
*
* @param string $url
* @return boolean
*/
public static function isImageFile(string $url): bool
{
$m = [];
@ -79,7 +91,7 @@ class Utils
*
* Si nv Charge < 0 => saturation
*
* @return void
* @return \App\Enums\LoadLevel
*/
public static function computeWorkload(): LoadLevel
{
@ -87,25 +99,25 @@ class Utils
$Tprogress = View::shared('ticketsInProgress');
$Twaiting = View::shared('ticketsWaiting');
$worked = config('tickets.workedHoursInAWeek');
$workedDays = config('tickets.workedDaysInAWeek');
$worked = config('tickets.workedHoursInAWeek');
$workedDays = config('tickets.workedDaysInAWeek');
// * in hours
$morningWorkTime = ($worked / $workedDays / 2);
// in hours
// * Devs handlings tickets per week
$workingDevsPerWeek = config('tickets.workingDevsPerWeek');
// Devs handlings tickets per week
// * Worktime per week to handle tickets
$timePerWeekDev = $workingDevsPerWeek * $morningWorkTime;
// Worktime per week to handle tickets
// * 45min for a ticket
$avgTimeForTicket = config('tickets.avgTimeForTicket');
// 45min for a ticket
// * The time needed to handle new ticket (average)
$todoListDelay = $Tnew * $avgTimeForTicket;
// The time needed to handle new ticket (average)
// * The time needed to handle new ticket (average)
$waitingListDelay = $Twaiting * $avgTimeForTicket;
// The time needed to handle new ticket (average)
// * The time needed to handle new ticket (average)
$progressListDelay = $Tprogress * $avgTimeForTicket;
// The time needed to handle new ticket (average)
$totalTicketsDelay = $todoListDelay + $progressListDelay + $waitingListDelay;
$ETAForAnewTicket = $totalTicketsDelay / $timePerWeekDev;
// in weeks
// * in weeks
$ETAForAnewTicket = $totalTicketsDelay / $timePerWeekDev;
switch ((int)round($ETAForAnewTicket)) {
case LoadLevel::weak()->value:
case LoadLevel::stable()->value:

View file

@ -0,0 +1,264 @@
<?php
namespace App\Models\MacroSchedule;
use App\Enums\MacroSchedule\Activity as MacroScheduleActivity;
use App\Models\Project;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
use Kwaadpepper\Enum\Traits\CastsEnums;
class Activity extends Model
{
use HasFactory;
use CastsEnums;
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'macro_activities';
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'start' => 'datetime',
'end' => 'datetime',
'load' => 'int',
];
/**
* The attributes that should be cast to Enums.
*
* @var array
*/
protected $enumCasts = [
'activity' => MacroScheduleActivity::class
];
/**
* Indicates if the model should be timestamped.
*
* @var boolean
*/
public $timestamps = false;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'activity',
'load',
'comment',
'start',
'end',
'project_id'
];
/**
* Perform any actions required after the model boots.
*
* @return void
*/
protected static function booted()
{
static::updating(function (self $activity) {
if (
$activity->getRawOriginal('start') !== $activity->start->format('Y-m-d H:i:s') and
$activity->start->clone()->setTime(0, 0)->lessThan(now()->setTime(0, 0))
) {
throw ValidationException::withMessages([
'Il n\'est pas possible de modifier la date de début à une date passé'
]);
}
if (
$activity->getRawOriginal('end') !== $activity->end->format('Y-m-d H:i:s') and
$activity->end->clone()->setTime(0, 0)->lessThan(now()->setTime(0, 0))
) {
throw ValidationException::withMessages([
'Il n\'est pas possible de modifier la date de fin à une date passé'
]);
}
if (Carbon::parse($activity->getRawOriginal('start'))->lessThan(now()->setTime(0, 0))) {
throw ValidationException::withMessages([
'Il n\'est pas possible de modifier la date de début d\'une activité qui est déjà passée'
]);
}
if (Carbon::parse($activity->getRawOriginal('end'))->lessThan(now()->setTime(0, 0))) {
throw ValidationException::withMessages([
'Il n\'est pas possible de modifier la date de fin d\'une activité dejà terminée'
]);
}
});
}
// * RELATIONSHIPS.
/**
* BelongsTo Project
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function project()
{
return $this->belongsTo(Project::class);
}
// * METHODS
/**
* Set the start date and specifiy wether it begins in
* the morning or in the afternoon.
*
* @param Carbon $date
* @param boolean $isMorning
* @return void
*/
public function setStartDate(Carbon $date, bool $isMorning)
{
$date = $isMorning ? $date->setTime(0, 0) : $date->setTime(12, 00);
$this->attributes['start'] = $date->format('Y-m-d H:i:s');
}
/**
* Set the end date and specifiy wether it begins in
* the morning or in the afternoon.
*
* @param Carbon $date
* @param boolean $isMorning
* @return void
*/
public function setEndDate(Carbon $date, bool $isMorning)
{
$date = $isMorning ? $date->setTime(11, 59) : $date->setTime(23, 59);
$this->attributes['end'] = $date->format('Y-m-d H:i:s');
}
/**
* Set the start date attribute.
*
* @param mixed $value
* @return void
* @throws \ErrorException This attribute cannot direct be assigned.
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
*/
public function setStartDateAttribute($value)
{
throw new \ErrorException('The start date attribute should be assigned only using setDate method');
}
/**
* Set the end date attribute.
*
* @param mixed $value
* @return void
* @throws \ErrorException This attribute cannot direct be assigned.
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
*/
public function setEndDateAttribute($value)
{
throw new \ErrorException('The end date attribute should be assigned only using setDate method');
}
/**
* Overlap activity using an other activity dates,
* over the source activity si completely overlaped,
* it will be removed
*
* @param self $activity
* @return void
* @throws \Throwable If updating (or removing) this activity failed.
* @throws \ErrorException If no overlap action was done even the two activities overlaps.
* @phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
*/
public function overlapUsingActivity(self $activity)
{
/** @var \Illuminate\Support\Carbon */
$selfStart = $this->start;
/** @var \Illuminate\Support\Carbon */
$selfEnd = $this->end;
/** @var \Illuminate\Support\Carbon */
$targetStart = $activity->start;
/** @var \Illuminate\Support\Carbon */
$targetEnd = $activity->end;
// ? Does target overlap self ?
if (!$activity->doesPeriodCoverActivity($selfStart, $selfEnd)) {
return;
}
// ? Does target completely overlap self ?
if ($targetStart->lessThanOrEqualTo($selfStart) and $targetEnd->greaterThanOrEqualTo($selfEnd)) {
$this->deleteOrFail();
return;
}
// ? Does target begins inside self ?
if ($selfStart->toPeriod($selfEnd)->contains($targetStart)) {
$newDate = $targetStart->clone()->subMinute();
if ($newDate->isWeekend()) {
// * ignore weekend
$newDate->previous(Carbon::FRIDAY);
$newDate->addMinute();
}
$this->setEndDate(
$newDate,
\boolval($newDate->hour < 12 and $newDate->hour >= 0)
);
$this->saveOrFail();
return;
}
// ? Does target ends inside self ?
if ($selfStart->toPeriod($selfEnd)->contains($targetEnd)) {
$newDate = $targetEnd->clone()->addMinute();
if ($newDate->isWeekend()) {
// * ignore weekend
$newDate->next(Carbon::MONDAY);
}
$this->setStartDate(
$newDate,
\boolval($newDate->hour < 12 and $newDate->hour >= 0)
);
$this->saveOrFail();
return;
}
throw new \ErrorException('No overlap action was done.');
}
/**
* Check a period covers this activity
*
* @param \Illuminate\Support\Carbon $start
* @param \Illuminate\Support\Carbon $end
* @return boolean
*/
public function doesPeriodCoverActivity(Carbon $start, Carbon $end): bool
{
if (!$this->start or !$this->end) {
return false;
}
$overlap = $start->toPeriod($end)->overlaps($this->start, $this->end);
return $overlap;
}
/**
* Convert the model instance to an array.
*
* @return array
*/
public function toArray()
{
return array_merge(parent::toArray(), [
'start' => $this->start->formatLocalized('%Y-%m-%dT%H:%M:%S'),
'end' => $this->end->formatLocalized('%Y-%m-%dT%H:%M:%S')
]);
}
}

View file

@ -0,0 +1,202 @@
<?php
namespace App\Models\MacroSchedule;
use App\Models\Project;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Kwaadpepper\CrudPolicies\Enums\CrudAction;
use Kwaadpepper\CrudPolicies\Enums\CrudType;
use Kwaadpepper\CrudPolicies\Traits\IsCrudModel;
class Marker extends Model
{
use HasFactory;
use IsCrudModel;
/**
* Crud label column
*
* @var string
*/
protected $crudLabelColumn = 'label';
/**
* Crud value column
*
* @var string
*/
protected $crudValueColumn = 'id';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'macro_markers';
/**
* Indicates if the model should be timestamped.
*
* @var boolean
*/
public $timestamps = false;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'label',
'marker'
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'marker' => 'datetime',
];
/**
* Maker
*
* @param array $attributes
*/
public function __construct(array $attributes = [])
{
self::$editableProperties = [
'label' => [
'label' => trans('Nom'),
'type' => CrudType::string(),
'validate' => [
'string',
'min:4',
'max:255'
],
'required' => true,
'nullable' => false,
'actions' => [
CrudAction::viewAny(),
CrudAction::view(),
CrudAction::create(),
CrudAction::update(),
]
],
'marker' => [
'label' => trans('Marqueur'),
'type' => CrudType::datetime(),
'validate' => [
'string',
'max:255'
],
'required' => true,
'nullable' => false,
'actions' => [
CrudAction::viewAny(),
CrudAction::view(),
CrudAction::create(),
CrudAction::update(),
]
]
];
parent::__construct($attributes);
}
/**
* Perform any actions required after the model boots.
*
* @return void
*/
protected static function booted()
{
static::creating(function (self $marker) {
if ($marker->markersThisSameDay()) {
throw ValidationException::withMessages(['Un marqueur existe déjà pour ce projet ce même jour']);
}
});
static::updating(function (self $marker) {
if ($marker->markersThisSameDay()) {
throw ValidationException::withMessages(['Un marqueur existe déjà pour ce projet ce même jour']);
}
});
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
static::created(function (self $marker) {
Cache::delete('markerLabels');
});
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
static::updated(function (self $marker) {
Cache::delete('markerLabels');
});
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
static::deleted(function (self $marker) {
Cache::delete('markerLabels');
});
}
// * METHODS
/**
* Get all available labels from cache.
* If cache was reset, it will fetch them from DB.
*
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function getCachedLabels()
{
if (!Cache::has('markerLabels')) {
self::setCachedLabels();
}
return Cache::get('markerLabels');
}
/**
* Put all available labels into cache.
*
* @return void
*/
protected static function setCachedLabels(): void
{
/** @var \Illuminate\Database\Eloquent\Builder $markerLabels */
$markerLabels = self::select(DB::raw('LOWER(label) AS label'));
$markerLabels->groupBy('label');
Cache::put('markerLabels', $markerLabels->get()->pluck('label'));
}
/**
* Count markers this same day
*
* @return integer
*/
public function markersThisSameDay(): int
{
/** @var \Illuminate\Database\Eloquent\Builder */
$query = self::where('marker', '>=', $this->marker->setHour(0, 0)->format('Y-m-d'))
->where('marker', '<', $this->marker->setHour(0, 0)->addDay()->format('Y-m-d'))
->whereHas('project', function ($q) {
$q->whereId($this->project->id);
});
if ($this->id) {
$query->where('id', '!=', $this->id);
}
return $query->count();
}
// * RELATIONSHIPS.
/**
* BelongsTo Project
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function project()
{
return $this->belongsTo(Project::class);
}
}

View file

@ -2,28 +2,62 @@
namespace App\Models;
use App\Enums\ProjectStatus;
use App\Events\ProjectUpdated;
use App\Models\MacroSchedule\Activity;
use App\Models\MacroSchedule\Marker;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Kwaadpepper\CrudPolicies\Enums\CrudAction;
use Kwaadpepper\CrudPolicies\Enums\CrudType;
use Kwaadpepper\CrudPolicies\Traits\IsCrudModel;
use Kwaadpepper\Enum\Traits\CastsEnums;
class Project extends Model
{
use HasFactory;
use IsCrudModel;
use CastsEnums;
/**
* Crud label column
*
* @var string
*/
protected $crudLabelColumn = 'name';
/**
* Crud value column
*
* @var string
*/
protected $crudValueColumn = 'id';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name', 'status',
'description', 'todo', 'sites'
];
public function __construct($attributes = [])
/**
* The attributes that should be cast to Enums.
*
* @var array
*/
protected $enumCasts = [
'status' => ProjectStatus::class
];
/**
* Project
*
* @param array $attributes
*/
public function __construct(array $attributes = [])
{
self::$editableProperties = [
'name' => [
@ -56,11 +90,8 @@ class Project extends Model
],
'status' => [
'label' => trans('État'),
'type' => CrudType::string(),
'validate' => [
'string',
'max:255'
],
'type' => CrudType::enum(),
'enum' => ProjectStatus::class,
'required' => true,
'nullable' => false,
'actions' => [
@ -114,11 +145,26 @@ class Project extends Model
CrudAction::view(),
CrudAction::create(),
]
]
],
'markers' => [
'label' => trans('Marqueurs'),
'type' => CrudType::hasMany(),
'hasMany' => Marker::class,
'hasManyLabel' => trans('Marqueurs'),
'nullable' => true,
'actions' => [
CrudAction::view(),
]
],
];
parent::__construct($attributes);
}
/**
* Perform any actions required after the model boots.
*
* @return void
*/
protected static function booted()
{
static::updated(function (Project $project) {
@ -126,18 +172,55 @@ class Project extends Model
});
}
// * RELATIONSHIPS.
/**
* BelongsToMany Site
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function sites()
{
return $this->belongsToMany(Site::class, 'project_site');
}
/**
* MorphMany Log
*
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function logs()
{
return $this->morphMany(Log::class, 'parent');
}
/**
* MorphMany File
*
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function files()
{
return $this->morphMany(File::class, 'parent');
}
/**
* HasMany Activity
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function activities()
{
return $this->hasMany(Activity::class);
}
/**
* HasMany Marker
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function markers()
{
return $this->hasMany(Marker::class);
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Policies\Rules\UserRoleRules as URR;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Database\Eloquent\Model;
use Kwaadpepper\CrudPolicies\Policies\RootPolicy;
class MarkerPolicy extends RootPolicy
{
use HandlesAuthorization;
/**
* Init policies on call
*
* @param User $user
* @param Model $model
* @return void
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassAfterLastUsed, Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassAfterLastUsed
*/
public function setPolicies(User $user, Model $model = null)
{
/**
* This first rule to return true in a policy action authorises the action
*/
$this->policies = [
'marker' => [
'viewAny' => URR::make('userIsAdmin', $user),
'view' => URR::make('userIsAdmin', $user),
'update' => URR::make('userIsAdmin', $user),
'create' => URR::make('userIsAdmin', $user),
'delete' => URR::make('userIsAdmin', $user)
]
];
}
}

View file

@ -15,11 +15,11 @@ class ProjectPolicy extends RootPolicy
/**
* Init policies on call
*
* @param User $user
* @param User $user
* @param Model $model
* @return void
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassAfterLastUsed, Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassAfterLastUsed
*/
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClassAfterLastUsed
public function setPolicies(User $user, Model $model = null)
{
/**

View file

@ -2,7 +2,9 @@
namespace App\Providers;
use App\Models\MacroSchedule\Marker;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
@ -35,10 +37,20 @@ class AppServiceProvider extends ServiceProvider
setlocale(LC_TIME, strtoupper(config('app.locale')) . "_" . config('app.locale') . '.utf8');
}
$appInfos = json_decode(File::get(\app_path('../composer.json')));
View::share('name', $appInfos->name);
View::share('version', $appInfos->version);
View::share('license', $appInfos->license);
if (!app()->runningInConsole()) {
$appInfos = Cache::remember('composer', 360, function () {
return json_decode(File::get(\app_path('../composer.json')));
});
View::share('name', $appInfos->name);
View::share('version', $appInfos->version);
View::share('license', $appInfos->license);
// * Groups
View::share('markerLabels', Cache::rememberForever('markerLabels', function () {
return Marker::getCachedLabels() ?? [];
}));
}//end if
}
}

View file

@ -4,6 +4,7 @@ namespace App\Providers;
use App\Models\File;
use App\Models\Log;
use App\Models\MacroSchedule\Marker;
use App\Models\Project;
use App\Models\Site;
use App\Models\Ticket;
@ -44,6 +45,7 @@ class RouteServiceProvider extends ServiceProvider
->namespace($this->namespace)
->group(base_path('routes/web.php'));
});
Route::model('marker', Marker::class);
Route::model('file', File::class);
Route::model('log', Log::class);
Route::model('user', User::class);

View file

@ -15,7 +15,7 @@
"fideloper/proxy": "^4.4",
"fruitcake/laravel-cors": "^2.0",
"guzzlehttp/guzzle": "^7.0.1",
"kwaadpepper/crud-policies": "^0.2.0",
"kwaadpepper/crud-policies": "^0.3.6",
"kwaadpepper/enum": "^2.1",
"kwaadpepper/exception-handler": "^0.0.5",
"laravel-lang/lang": "~8.0",

237
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "78edc488b98b5b88d1345581be7e49a2",
"content-hash": "2a618153177d1ef96c1f71551d669f8c",
"packages": [
{
"name": "asm89/stack-cors",
@ -223,16 +223,16 @@
},
{
"name": "doctrine/dbal",
"version": "3.3.4",
"version": "3.3.5",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
"reference": "83f779beaea1893c0bece093ab2104c6d15a7f26"
"reference": "719663b15983278227669c8595151586a2ff3327"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/83f779beaea1893c0bece093ab2104c6d15a7f26",
"reference": "83f779beaea1893c0bece093ab2104c6d15a7f26",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/719663b15983278227669c8595151586a2ff3327",
"reference": "719663b15983278227669c8595151586a2ff3327",
"shasum": ""
},
"require": {
@ -247,7 +247,7 @@
"require-dev": {
"doctrine/coding-standard": "9.0.0",
"jetbrains/phpstorm-stubs": "2021.1",
"phpstan/phpstan": "1.4.6",
"phpstan/phpstan": "1.5.3",
"phpstan/phpstan-strict-rules": "^1.1",
"phpunit/phpunit": "9.5.16",
"psalm/plugin-phpunit": "0.16.1",
@ -314,7 +314,7 @@
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
"source": "https://github.com/doctrine/dbal/tree/3.3.4"
"source": "https://github.com/doctrine/dbal/tree/3.3.5"
},
"funding": [
{
@ -330,7 +330,7 @@
"type": "tidelift"
}
],
"time": "2022-03-20T18:37:29+00:00"
"time": "2022-04-05T09:50:18+00:00"
},
{
"name": "doctrine/deprecations",
@ -1511,23 +1511,24 @@
},
{
"name": "kwaadpepper/crud-policies",
"version": "0.2.1",
"version": "0.3.6",
"source": {
"type": "git",
"url": "https://github.com/Kwaadpepper/crud-policies.git",
"reference": "b877abff301bdf29195ac21f9e6685c71ce494f6"
"reference": "205b8a16c99c362fc78422eeb31c0ba618a3d351"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Kwaadpepper/crud-policies/zipball/b877abff301bdf29195ac21f9e6685c71ce494f6",
"reference": "b877abff301bdf29195ac21f9e6685c71ce494f6",
"url": "https://api.github.com/repos/Kwaadpepper/crud-policies/zipball/205b8a16c99c362fc78422eeb31c0ba618a3d351",
"reference": "205b8a16c99c362fc78422eeb31c0ba618a3d351",
"shasum": ""
},
"require": {
"doctrine/dbal": "^3.1",
"facade/flare-client-php": "^1.8",
"illuminate/support": "~7|~8",
"illuminate/support": "~8",
"kwaadpepper/enum": "^2.0",
"laravel/framework": "~7|~8",
"laravel/framework": "~8",
"mews/purifier": "^3.3"
},
"require-dev": {
@ -1570,9 +1571,9 @@
],
"support": {
"issues": "https://github.com/Kwaadpepper/crud-policies/issues",
"source": "https://github.com/Kwaadpepper/crud-policies/tree/0.2.1"
"source": "https://github.com/Kwaadpepper/crud-policies/tree/0.3.6"
},
"time": "2021-11-17T11:35:53+00:00"
"time": "2022-05-04T13:19:25+00:00"
},
{
"name": "kwaadpepper/enum",
@ -1750,16 +1751,16 @@
},
{
"name": "laravel/framework",
"version": "v8.83.5",
"version": "v8.83.9",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "33b1b981266e3a19fbc826b60c4a6847e311ac95"
"reference": "ac6e16bb56510eda2e373c6365d5c92da3fd559d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/33b1b981266e3a19fbc826b60c4a6847e311ac95",
"reference": "33b1b981266e3a19fbc826b60c4a6847e311ac95",
"url": "https://api.github.com/repos/laravel/framework/zipball/ac6e16bb56510eda2e373c6365d5c92da3fd559d",
"reference": "ac6e16bb56510eda2e373c6365d5c92da3fd559d",
"shasum": ""
},
"require": {
@ -1919,7 +1920,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2022-03-15T13:37:44+00:00"
"time": "2022-04-19T15:01:38+00:00"
},
{
"name": "laravel/serializable-closure",
@ -1982,16 +1983,16 @@
},
{
"name": "laravel/tinker",
"version": "v2.7.1",
"version": "v2.7.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/tinker.git",
"reference": "1e2d500585a4e546346fadd3adc6f9c1a97e15f4"
"reference": "dff39b661e827dae6e092412f976658df82dbac5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/tinker/zipball/1e2d500585a4e546346fadd3adc6f9c1a97e15f4",
"reference": "1e2d500585a4e546346fadd3adc6f9c1a97e15f4",
"url": "https://api.github.com/repos/laravel/tinker/zipball/dff39b661e827dae6e092412f976658df82dbac5",
"reference": "dff39b661e827dae6e092412f976658df82dbac5",
"shasum": ""
},
"require": {
@ -2044,9 +2045,9 @@
],
"support": {
"issues": "https://github.com/laravel/tinker/issues",
"source": "https://github.com/laravel/tinker/tree/v2.7.1"
"source": "https://github.com/laravel/tinker/tree/v2.7.2"
},
"time": "2022-03-15T15:25:01+00:00"
"time": "2022-03-23T12:38:24+00:00"
},
{
"name": "laravel/ui",
@ -2298,16 +2299,16 @@
},
{
"name": "league/mime-type-detection",
"version": "1.9.0",
"version": "1.11.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/mime-type-detection.git",
"reference": "aa70e813a6ad3d1558fc927863d47309b4c23e69"
"reference": "ff6248ea87a9f116e78edd6002e39e5128a0d4dd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/aa70e813a6ad3d1558fc927863d47309b4c23e69",
"reference": "aa70e813a6ad3d1558fc927863d47309b4c23e69",
"url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/ff6248ea87a9f116e78edd6002e39e5128a0d4dd",
"reference": "ff6248ea87a9f116e78edd6002e39e5128a0d4dd",
"shasum": ""
},
"require": {
@ -2338,7 +2339,7 @@
"description": "Mime-type detection for Flysystem",
"support": {
"issues": "https://github.com/thephpleague/mime-type-detection/issues",
"source": "https://github.com/thephpleague/mime-type-detection/tree/1.9.0"
"source": "https://github.com/thephpleague/mime-type-detection/tree/1.11.0"
},
"funding": [
{
@ -2350,7 +2351,7 @@
"type": "tidelift"
}
],
"time": "2021-11-21T11:48:40+00:00"
"time": "2022-04-17T13:12:02+00:00"
},
{
"name": "mews/purifier",
@ -2435,16 +2436,16 @@
},
{
"name": "monolog/monolog",
"version": "2.4.0",
"version": "2.5.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
"reference": "d7fd7450628561ba697b7097d86db72662f54aef"
"reference": "4192345e260f1d51b365536199744b987e160edc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/d7fd7450628561ba697b7097d86db72662f54aef",
"reference": "d7fd7450628561ba697b7097d86db72662f54aef",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/4192345e260f1d51b365536199744b987e160edc",
"reference": "4192345e260f1d51b365536199744b987e160edc",
"shasum": ""
},
"require": {
@ -2518,7 +2519,7 @@
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
"source": "https://github.com/Seldaek/monolog/tree/2.4.0"
"source": "https://github.com/Seldaek/monolog/tree/2.5.0"
},
"funding": [
{
@ -2530,7 +2531,7 @@
"type": "tidelift"
}
],
"time": "2022-03-14T12:44:37+00:00"
"time": "2022-04-08T15:43:54+00:00"
},
{
"name": "nesbot/carbon",
@ -3605,16 +3606,16 @@
},
{
"name": "symfony/console",
"version": "v5.4.5",
"version": "v5.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "d8111acc99876953f52fe16d4c50eb60940d49ad"
"reference": "900275254f0a1a2afff1ab0e11abd5587a10e1d6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/d8111acc99876953f52fe16d4c50eb60940d49ad",
"reference": "d8111acc99876953f52fe16d4c50eb60940d49ad",
"url": "https://api.github.com/repos/symfony/console/zipball/900275254f0a1a2afff1ab0e11abd5587a10e1d6",
"reference": "900275254f0a1a2afff1ab0e11abd5587a10e1d6",
"shasum": ""
},
"require": {
@ -3684,7 +3685,7 @@
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v5.4.5"
"source": "https://github.com/symfony/console/tree/v5.4.7"
},
"funding": [
{
@ -3700,7 +3701,7 @@
"type": "tidelift"
}
],
"time": "2022-02-24T12:45:35+00:00"
"time": "2022-03-31T17:09:19+00:00"
},
{
"name": "symfony/css-selector",
@ -3770,16 +3771,16 @@
},
{
"name": "symfony/deprecation-contracts",
"version": "v2.5.0",
"version": "v2.5.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "6f981ee24cf69ee7ce9736146d1c57c2780598a8"
"reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/6f981ee24cf69ee7ce9736146d1c57c2780598a8",
"reference": "6f981ee24cf69ee7ce9736146d1c57c2780598a8",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/e8b495ea28c1d97b5e0c121748d6f9b53d075c66",
"reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66",
"shasum": ""
},
"require": {
@ -3817,7 +3818,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.0"
"source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.1"
},
"funding": [
{
@ -3833,20 +3834,20 @@
"type": "tidelift"
}
],
"time": "2021-07-12T14:48:14+00:00"
"time": "2022-01-02T09:53:40+00:00"
},
{
"name": "symfony/error-handler",
"version": "v5.4.3",
"version": "v5.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/error-handler.git",
"reference": "c4ffc2cd919950d13c8c9ce32a70c70214c3ffc5"
"reference": "060bc01856a1846e3e4385261bc9ed11a1dd7b6a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/c4ffc2cd919950d13c8c9ce32a70c70214c3ffc5",
"reference": "c4ffc2cd919950d13c8c9ce32a70c70214c3ffc5",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/060bc01856a1846e3e4385261bc9ed11a1dd7b6a",
"reference": "060bc01856a1846e3e4385261bc9ed11a1dd7b6a",
"shasum": ""
},
"require": {
@ -3888,7 +3889,7 @@
"description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/error-handler/tree/v5.4.3"
"source": "https://github.com/symfony/error-handler/tree/v5.4.7"
},
"funding": [
{
@ -3904,7 +3905,7 @@
"type": "tidelift"
}
],
"time": "2022-01-02T09:53:40+00:00"
"time": "2022-03-18T16:21:29+00:00"
},
{
"name": "symfony/event-dispatcher",
@ -3993,16 +3994,16 @@
},
{
"name": "symfony/event-dispatcher-contracts",
"version": "v2.5.0",
"version": "v2.5.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher-contracts.git",
"reference": "66bea3b09be61613cd3b4043a65a8ec48cfa6d2a"
"reference": "f98b54df6ad059855739db6fcbc2d36995283fe1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/66bea3b09be61613cd3b4043a65a8ec48cfa6d2a",
"reference": "66bea3b09be61613cd3b4043a65a8ec48cfa6d2a",
"url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/f98b54df6ad059855739db6fcbc2d36995283fe1",
"reference": "f98b54df6ad059855739db6fcbc2d36995283fe1",
"shasum": ""
},
"require": {
@ -4052,7 +4053,7 @@
"standards"
],
"support": {
"source": "https://github.com/symfony/event-dispatcher-contracts/tree/v2.5.0"
"source": "https://github.com/symfony/event-dispatcher-contracts/tree/v2.5.1"
},
"funding": [
{
@ -4068,7 +4069,7 @@
"type": "tidelift"
}
],
"time": "2021-07-12T14:48:14+00:00"
"time": "2022-01-02T09:53:40+00:00"
},
{
"name": "symfony/finder",
@ -4208,16 +4209,16 @@
},
{
"name": "symfony/http-kernel",
"version": "v5.4.6",
"version": "v5.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
"reference": "d41f29ae9af1b5f40c7ebcddf09082953229411d"
"reference": "509243b9b3656db966284c45dffce9316c1ecc5c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/d41f29ae9af1b5f40c7ebcddf09082953229411d",
"reference": "d41f29ae9af1b5f40c7ebcddf09082953229411d",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/509243b9b3656db966284c45dffce9316c1ecc5c",
"reference": "509243b9b3656db966284c45dffce9316c1ecc5c",
"shasum": ""
},
"require": {
@ -4300,7 +4301,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-kernel/tree/v5.4.6"
"source": "https://github.com/symfony/http-kernel/tree/v5.4.7"
},
"funding": [
{
@ -4316,20 +4317,20 @@
"type": "tidelift"
}
],
"time": "2022-03-05T21:14:51+00:00"
"time": "2022-04-02T06:04:20+00:00"
},
{
"name": "symfony/mime",
"version": "v5.4.3",
"version": "v5.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "e1503cfb5c9a225350f549d3bb99296f4abfb80f"
"reference": "92d27a34dea2e199fa9b687e3fff3a7d169b7b1c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/e1503cfb5c9a225350f549d3bb99296f4abfb80f",
"reference": "e1503cfb5c9a225350f549d3bb99296f4abfb80f",
"url": "https://api.github.com/repos/symfony/mime/zipball/92d27a34dea2e199fa9b687e3fff3a7d169b7b1c",
"reference": "92d27a34dea2e199fa9b687e3fff3a7d169b7b1c",
"shasum": ""
},
"require": {
@ -4383,7 +4384,7 @@
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v5.4.3"
"source": "https://github.com/symfony/mime/tree/v5.4.7"
},
"funding": [
{
@ -4399,7 +4400,7 @@
"type": "tidelift"
}
],
"time": "2022-01-02T09:53:40+00:00"
"time": "2022-03-11T16:08:05+00:00"
},
{
"name": "symfony/polyfill-ctype",
@ -5220,16 +5221,16 @@
},
{
"name": "symfony/process",
"version": "v5.4.5",
"version": "v5.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
"reference": "95440409896f90a5f85db07a32b517ecec17fa4c"
"reference": "38a44b2517b470a436e1c944bf9b9ba3961137fb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/95440409896f90a5f85db07a32b517ecec17fa4c",
"reference": "95440409896f90a5f85db07a32b517ecec17fa4c",
"url": "https://api.github.com/repos/symfony/process/zipball/38a44b2517b470a436e1c944bf9b9ba3961137fb",
"reference": "38a44b2517b470a436e1c944bf9b9ba3961137fb",
"shasum": ""
},
"require": {
@ -5262,7 +5263,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/process/tree/v5.4.5"
"source": "https://github.com/symfony/process/tree/v5.4.7"
},
"funding": [
{
@ -5278,7 +5279,7 @@
"type": "tidelift"
}
],
"time": "2022-01-30T18:16:22+00:00"
"time": "2022-03-18T16:18:52+00:00"
},
{
"name": "symfony/routing",
@ -5372,22 +5373,22 @@
},
{
"name": "symfony/service-contracts",
"version": "v2.5.0",
"version": "v2.5.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
"reference": "1ab11b933cd6bc5464b08e81e2c5b07dec58b0fc"
"reference": "24d9dc654b83e91aa59f9d167b131bc3b5bea24c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/1ab11b933cd6bc5464b08e81e2c5b07dec58b0fc",
"reference": "1ab11b933cd6bc5464b08e81e2c5b07dec58b0fc",
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/24d9dc654b83e91aa59f9d167b131bc3b5bea24c",
"reference": "24d9dc654b83e91aa59f9d167b131bc3b5bea24c",
"shasum": ""
},
"require": {
"php": ">=7.2.5",
"psr/container": "^1.1",
"symfony/deprecation-contracts": "^2.1"
"symfony/deprecation-contracts": "^2.1|^3"
},
"conflict": {
"ext-psr": "<1.1|>=2"
@ -5435,7 +5436,7 @@
"standards"
],
"support": {
"source": "https://github.com/symfony/service-contracts/tree/v2.5.0"
"source": "https://github.com/symfony/service-contracts/tree/v2.5.1"
},
"funding": [
{
@ -5451,7 +5452,7 @@
"type": "tidelift"
}
],
"time": "2021-11-04T16:48:04+00:00"
"time": "2022-03-13T20:07:29+00:00"
},
{
"name": "symfony/string",
@ -5541,16 +5542,16 @@
},
{
"name": "symfony/translation",
"version": "v5.4.6",
"version": "v5.4.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation.git",
"reference": "a7ca9fdfffb0174209440c2ffa1dee228e15d95b"
"reference": "e1eb790575202ee3ac2659f55b93b05853726f8e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/translation/zipball/a7ca9fdfffb0174209440c2ffa1dee228e15d95b",
"reference": "a7ca9fdfffb0174209440c2ffa1dee228e15d95b",
"url": "https://api.github.com/repos/symfony/translation/zipball/e1eb790575202ee3ac2659f55b93b05853726f8e",
"reference": "e1eb790575202ee3ac2659f55b93b05853726f8e",
"shasum": ""
},
"require": {
@ -5618,7 +5619,7 @@
"description": "Provides tools to internationalize your application",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/translation/tree/v5.4.6"
"source": "https://github.com/symfony/translation/tree/v5.4.7"
},
"funding": [
{
@ -5634,20 +5635,20 @@
"type": "tidelift"
}
],
"time": "2022-03-02T12:56:28+00:00"
"time": "2022-03-24T17:09:09+00:00"
},
{
"name": "symfony/translation-contracts",
"version": "v2.5.0",
"version": "v2.5.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation-contracts.git",
"reference": "d28150f0f44ce854e942b671fc2620a98aae1b1e"
"reference": "1211df0afa701e45a04253110e959d4af4ef0f07"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/d28150f0f44ce854e942b671fc2620a98aae1b1e",
"reference": "d28150f0f44ce854e942b671fc2620a98aae1b1e",
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/1211df0afa701e45a04253110e959d4af4ef0f07",
"reference": "1211df0afa701e45a04253110e959d4af4ef0f07",
"shasum": ""
},
"require": {
@ -5696,7 +5697,7 @@
"standards"
],
"support": {
"source": "https://github.com/symfony/translation-contracts/tree/v2.5.0"
"source": "https://github.com/symfony/translation-contracts/tree/v2.5.1"
},
"funding": [
{
@ -5712,7 +5713,7 @@
"type": "tidelift"
}
],
"time": "2021-08-17T14:20:01+00:00"
"time": "2022-01-02T09:53:40+00:00"
},
{
"name": "symfony/var-dumper",
@ -7040,16 +7041,16 @@
},
{
"name": "phpstan/phpdoc-parser",
"version": "1.4.1",
"version": "1.4.5",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "05333065c88f9518e08b094bdd10a4a9e538b2d4"
"reference": "129a63b3bc7caeb593c224c41f420675e63cfefc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/05333065c88f9518e08b094bdd10a4a9e538b2d4",
"reference": "05333065c88f9518e08b094bdd10a4a9e538b2d4",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/129a63b3bc7caeb593c224c41f420675e63cfefc",
"reference": "129a63b3bc7caeb593c224c41f420675e63cfefc",
"shasum": ""
},
"require": {
@ -7078,9 +7079,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/1.4.1"
"source": "https://github.com/phpstan/phpdoc-parser/tree/1.4.5"
},
"time": "2022-03-29T09:28:16+00:00"
"time": "2022-04-22T11:11:01+00:00"
},
{
"name": "phpunit/php-code-coverage",
@ -7402,16 +7403,16 @@
},
{
"name": "phpunit/phpunit",
"version": "9.5.19",
"version": "9.5.20",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "35ea4b7f3acabb26f4bb640f8c30866c401da807"
"reference": "12bc8879fb65aef2138b26fc633cb1e3620cffba"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/35ea4b7f3acabb26f4bb640f8c30866c401da807",
"reference": "35ea4b7f3acabb26f4bb640f8c30866c401da807",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/12bc8879fb65aef2138b26fc633cb1e3620cffba",
"reference": "12bc8879fb65aef2138b26fc633cb1e3620cffba",
"shasum": ""
},
"require": {
@ -7489,7 +7490,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.19"
"source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.20"
},
"funding": [
{
@ -7501,7 +7502,7 @@
"type": "github"
}
],
"time": "2022-03-15T09:57:31+00:00"
"time": "2022-04-01T12:37:26+00:00"
},
{
"name": "sebastian/cli-parser",
@ -7869,16 +7870,16 @@
},
{
"name": "sebastian/environment",
"version": "5.1.3",
"version": "5.1.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/environment.git",
"reference": "388b6ced16caa751030f6a69e588299fa09200ac"
"reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/388b6ced16caa751030f6a69e588299fa09200ac",
"reference": "388b6ced16caa751030f6a69e588299fa09200ac",
"url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/1b5dff7bb151a4db11d49d90e5408e4e938270f7",
"reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7",
"shasum": ""
},
"require": {
@ -7920,7 +7921,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/environment/issues",
"source": "https://github.com/sebastianbergmann/environment/tree/5.1.3"
"source": "https://github.com/sebastianbergmann/environment/tree/5.1.4"
},
"funding": [
{
@ -7928,7 +7929,7 @@
"type": "github"
}
],
"time": "2020-09-28T05:52:38+00:00"
"time": "2022-04-03T09:37:03+00:00"
},
{
"name": "sebastian/exporter",

View file

@ -139,6 +139,7 @@ return [
/*
* Laravel Framework Service Providers...
*/
Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
Illuminate\Bus\BusServiceProvider::class,
@ -169,6 +170,7 @@ return [
/*
* Application Service Providers...
*/
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
App\Providers\EventServiceProvider::class,
@ -226,6 +228,9 @@ return [
'URL' => Illuminate\Support\Facades\URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
'Carbon' => 'Carbon\Carbon',
// * Application custom
'TicketType' => App\Enums\TicketType::class,
'TicketStatus' => App\Enums\TicketStatus::class,
'Utils' => App\Lib\Utils::class,
@ -238,7 +243,8 @@ return [
'CellType' => App\Enums\Planning\CellType::class,
'ActivityType' => App\Enums\Planning\ActivityType::class,
'TableRow' => App\Enums\Planning\TableRow::class,
'Carbon' => 'Carbon\Carbon'
'ActivityEnum' => App\Enums\MacroSchedule\Activity::class,
'ProjectStatus' => App\Enums\ProjectStatus::class,
],
];

8
config/macroschedule.php Normal file
View file

@ -0,0 +1,8 @@
<?php
return [
'numberOfWeeksToShow' => 3,
'totalAvailableDevs' => 6,
// * En pourcentage
'loadDevThreshold' => 0.5,
];

View file

@ -0,0 +1,30 @@
<?php
namespace Database\Factories\MacroSchedule;
use App\Models\MacroSchedule\Activity;
use Illuminate\Database\Eloquent\Factories\Factory;
class ActivityFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Activity::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
\mt_srand();
return [
'load' => \mt_rand(1, 3),
'comment' => $this->faker->sentence(mt_rand(2, 6))
];
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Database\Factories\MacroSchedule;
use App\Models\MacroSchedule\Marker;
use Illuminate\Database\Eloquent\Factories\Factory;
class MarkerFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Marker::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
\mt_srand();
return [
'label' => $this->faker->sentence(mt_rand(2, 6))
];
}
}

View file

@ -0,0 +1,37 @@
<?php
use App\Models\Project;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateMarkersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('macro_markers', function (Blueprint $table) {
$table->id();
$table->string('label')->comment('Le libellé du marqueur.');
$table->dateTime('marker')->comment('Le point dans le temps du marqueur.');
$table->foreignIdFor(Project::class)->constrained()
->onUpdate('cascade')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('macro_markers');
}
}

View file

@ -0,0 +1,39 @@
<?php
use App\Models\Project;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateActivitiesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('macro_activities', function (Blueprint $table) {
$table->id();
$table->dateTime('start')->comment('La date de début de l\'activité.');
$table->dateTime('end')->comment('La date de fin de l\'activité.');
$table->unsignedSmallInteger('activity')->comment('Enum MacroSchedule/Activity');
$table->unsignedSmallInteger('load')->comment('Le nombre d\'hommes qui vont être mobilisé pour la tâche');
$table->string('comment')->comment('Commentaire sur la tâche, (255 chars max)');
$table->foreignIdFor(Project::class)->constrained()
->onUpdate('cascade')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('macro_activities');
}
}

View file

@ -0,0 +1,35 @@
<?php
use App\Models\Project;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class ChangeStatusOnProjectsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Project::query()->update(['status' => '0']);
Schema::table('projects', function (Blueprint $table) {
$table->unsignedSmallInteger('status')->default(0)
->comment('Enum ProjectStatus')->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('projects', function (Blueprint $table) {
$table->string('status')->default('')->change();
});
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace Database\Seeders;
use App\Enums\MacroSchedule\Activity;
use App\Models\MacroSchedule\Activity as MacroScheduleActivity;
use App\Models\Project;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class MacroScheduleSeeder extends Seeder
{
/**
* Seed the application's database.
*
* @return void
*/
public function run()
{
/** Reset all Activities */
DB::table((new MacroScheduleActivity())->getTable())->truncate();
\mt_srand();
$startDate = Carbon::now()->subWeeks(1)->setTime(0, 0);
$activityDays = function () {
return \mt_rand(1, 3);
};
$activityGutter = function () {
return mt_rand(0, 6);
};
/** @var \Illuminate\Database\Eloquent\Collection<\App\Models\Project> $projects */
$projects = Project::all();
$projects->map(function (Project $project) use ($startDate, $activityDays, $activityGutter) {
$projectStart = $startDate->clone();
/** @var \Kwaadpepper\Enum\BaseEnum[]<\App\Enums\MacroSchedule\Activity> */
$activities = Activity::toArray();
foreach ($activities as $activityEnum) {
/** @var integer Activity length in days */
$activityLength = $activityDays();
/** @var integer Activity gutter length in days */
$activityGutterLength = $activityGutter();
/** @var \App\Models\MacroSchedule\Activity */
$modelPlanning = MacroScheduleActivity::factory(1)->make()->first();
$activityModel = new MacroScheduleActivity();
$activityModel->setStartDate($projectStart, rand(0, 1));
$projectStart = $projectStart->addDays($activityLength);
$activityModel->setEndDate($projectStart, rand(0, 1));
$activityModel->comment = $modelPlanning->comment;
$activityModel->load = $modelPlanning->load;
$activityModel->activity = $activityEnum;
$activityModel->project()->associate($project);
$activityModel->saveOrFail();
// * Sub Gutter to period
$projectStart = $projectStart->addDays($activityGutterLength);
} //end foreach
});
}
}

View file

@ -1,29 +0,0 @@
<?php
namespace Database\Seeders;
use App\Enums\Role;
use App\Models\User;
use App\Models\WeekPlanning;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
class WeekPlanningSeeder extends Seeder
{
/**
* Seed the application's database.
*
* @return void
*/
public function run()
{
WeekPlanning::truncate();
/** \App\Models\User $user */
foreach (User::where('role', Role::developper())->get() as $user) {
WeekPlanning::factory(13)->create([
'start' => Carbon::now()->startOfWeek(),
'user_id' => $user->id
]);
}
}
}

5
jsconfig.json Normal file
View file

@ -0,0 +1,5 @@
{
"include": [
"./resources/js/**/*"
]
}

2608
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -20,7 +20,7 @@
"laravel-mix": "^6.0.16",
"laravel-mix-polyfill": "^3.0.1",
"lodash": "^4.17.19",
"moment": "^2.29.1",
"moment": "^2.29.3",
"resolve-url-loader": "^3.1.0",
"sass": "^1.15.2",
"sass-loader": "^8.0.0",

72
resources/js/app.js vendored
View file

@ -3,75 +3,16 @@ require('./session_watch');
//window.Vue = require('vue/dist/vue.min.js');
import Vue from 'vue'
import moment from 'moment'
import vSelect from "vue-select"
import 'moment/locale/fr'
import vSelect from 'vue-select'
moment.locale('fr')
// v-select
Vue.component('v-select', vSelect)
// Configure moment
moment.locale('fr', {
months : 'janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre'.split('_'),
monthsShort : 'janv._févr._mars_avr._mai_juin_juil._août_sept._oct._nov._déc.'.split('_'),
monthsParseExact : true,
weekdays : 'dimanche_lundi_mardi_mercredi_jeudi_vendredi_samedi'.split('_'),
weekdaysShort : 'dim._lun._mar._mer._jeu._ven._sam.'.split('_'),
weekdaysMin : 'Di_Lu_Ma_Me_Je_Ve_Sa'.split('_'),
weekdaysParseExact : true,
longDateFormat : {
LT : 'HH:mm',
LTS : 'HH:mm:ss',
L : 'DD/MM/YYYY',
LL : 'D MMMM YYYY',
LLL : 'D MMMM YYYY HH:mm',
LLLL : 'dddd D MMMM YYYY HH:mm'
},
calendar : {
sameDay : '[Aujourd’hui à] LT',
nextDay : '[Demain à] LT',
nextWeek : 'dddd [à] LT',
lastDay : '[Hier à] LT',
lastWeek : 'dddd [dernier à] LT',
sameElse : 'L'
},
relativeTime : {
future : 'dans %s',
past : 'il y a %s',
s : 'quelques secondes',
m : 'une minute',
mm : '%d minutes',
h : 'une heure',
hh : '%d heures',
d : 'un jour',
dd : '%d jours',
M : 'un mois',
MM : '%d mois',
y : 'un an',
yy : '%d ans'
},
dayOfMonthOrdinalParse : /\d{1,2}(er|e)/,
ordinal : function (number) {
return number + (number === 1 ? 'er' : 'e');
},
meridiemParse : /PD|MD/,
isPM : function (input) {
return input.charAt(0) === 'M';
},
// In case the meridiem units are not separated around 12, then implement
// this function (look at locale/id.js for an example).
// meridiemHour : function (hour, meridiem) {
// return /* 0-23 hour, given meridiem token and hour 1-12 */ ;
// },
meridiem : function (hours, minutes, isLower) {
return hours < 12 ? 'PD' : 'MD';
},
week : {
dow : 1, // Monday is the first day of the week.
doy : 4 // Used to determine first week of the year.
}
});
moment.locale('fr');
Vue.prototype.moment = moment
Vue.prototype.bootstrap = bootstrap
// Register modules
const modules = require.context("./modules", true, /\.js$/i);
@ -102,6 +43,9 @@ Vue.filter('str_limit', function (value, size) {
}
return value.substr(0, size) + '...';
});
Vue.filter('ucfirst', function (value) {
return value.charAt(0).toUpperCase() + value.slice(1)
});
for (let el of document.getElementsByClassName('vueComponent')) {
new Vue({ el: el });

View file

@ -0,0 +1,755 @@
<template>
<!-- TODO: order by name, status -->
<div ref="macroschedule" class="macroschedule">
<div class="row mb-3">
<div class="col-1">
<button class="btn btn-primary" @click.prevent="goPreviousWeek()">←</button>
</div>
<div class="col">
<h1 class="text-secondary">Plan de charge</h1>
<div class="input-group">
<label for="scheduleDateSelect" class="input-group-text">Voir date&nbsp;:&nbsp;</label>
<input id="scheduleDateSelect" class="form-control" type="date" :value="startMoment.format('YYYY-MM-DD')" @input="showMacroScheduleDate($event.target.value)">
</div>
</div>
<div class="col">
<fieldset>
<legend>Légende</legend>
<template v-if="isAdmin">
<span v-for="activityEnum of activitiesEnum" :key="activityEnum.value" :class="'p-1 m-2 border badge badge-activity-' + activityEnum.value">{{ activityEnum.label }}</span>
<span class="p-1 m-2 border badge badge-marker">Marqueur</span>
<span class="p-1 m-2 border badge badge-today">Aujourd'hui</span>
</template>
<template v-else>
<span class="p-1 m-2 border badge badge-activity-light">Libre</span>
<span class="p-1 m-2 border badge badge-activity-dark">Occupé</span>
</template>
</fieldset>
</div>
<div class="col-1">
<button class="btn btn-primary" @click.prevent="goNextWeek()">→</button>
</div>
</div>
<div class="row justify-content-center">
<div class="col-12">
<table class="table table-bordered">
<thead>
<tr>
<th scope="col" rowspan="3" class="align-middle">{{ isAdmin ? 'Projets' : '' }}</th>
<th scope="col" rowspan="3" class="align-middle">{{ isAdmin ? 'Statut' : '' }}</th>
<th v-if="daysThisMonth.length" :colspan="daysThisMonth.length" class="text-center">
{{ daysThisMonth[0].format('MMMM') | ucfirst }}
</th>
<th v-if="daysNextMonth.length" :colspan="daysNextMonth.length" class="text-center">
{{ daysNextMonth[0].format('MMMM') | ucfirst }}
</th>
</tr>
<tr>
<th v-for="weekNumber in macroscheduleConfig.numberOfWeeksToShow" :key="weekNumber" :colspan="7 - 2" class="text-center">
SEMAINE&nbsp;{{ currentWeek + weekNumber - 1}}
</th>
</tr>
<tr>
<template v-for="(currentDay, index) of daysToShow">
<th scope="col" v-if="dayShouldbeenSeen(currentDay)" :key="index" :class="'dayCell p-0 text-center ' + (currentDay.isSame(todayMoment, 'day') ? 'today' : '')">
{{ currentDay.format('dd') }}&nbsp;{{ String(currentDay.date()).padStart(2, '0') }}
</th>
</template>
</tr>
</thead>
<tbody>
<template v-if="isAdmin">
<tr v-for="project of projects" :key="project.id" class="projectRow">
<th scope="col">
<span :class="'rounded-1 p-1 project text-nowrap text-truncate overflow-hidden bg-enum-'+project.status">{{ project.name | str_limit(45) }}</span>
</th>
<td>
<span class="fw-bold text-nowrap text-truncate overflow-hidden">{{ projectStatusEnum[project.status].label }}</span>
</td>
<template v-for="(currentDay, index) of daysToShow">
<td :class="'p-0 border border-dark activities ' + (currentDay.isSame(todayMoment, 'day') ? 'today' : '')" v-if="dayShouldbeenSeen(currentDay)" :key="index">
<div
v-for="(activity, index) in [getActivity(project, getMorningMoment(currentDay)), getActivity(project, getAfterNoonMoment(currentDay))]"
:key="index"
:class="getActivityClass(activity)+ ' activity ' + (hoverActivityId === activity.id ? 'hover' : '')"
@click="showModale(project, activity, currentDay, index === 0)"
@mouseover="setActivityHover(activity)"
@mouseleave="setActivityHover(activity, false)">
<div :title="getActivityTooltip(activity)" data-bs-toggle="tooltip">
&nbsp;
</div>
<template v-if="projectHasMarkersForTheDay(currentDay, project)">
<div
class="marker"
data-bs-toggle="tooltip"
data-bs-html="true"
:title="getMarkersTooltip(currentDay, project)"></div>
</template>
</div>
</td>
</template>
</tr>
</template>
<tr class="projectRow">
<th scope="col" class="border-end-0 d-flex justify-content-between"><span class="fw-bold h5">Disponibilité</span></th>
<td class="border-start-0"></td>
<template v-for="(currentDay, index) of daysToShow">
<td :class="'p-0 border border-dark activities ' + (currentDay.isSame(todayMoment, 'day') ? 'today' : '')" v-if="dayShouldbeenSeen(currentDay)" :key="index">
<template v-if="isAdmin">
<div class="activity text-center" :style="{ 'background-color' : 'rgba(0,0,0,'+ getLoadPercent(getMorningMoment(currentDay)) }">
{{ getLoad(getMorningMoment(currentDay)) > 1 ? '&#128293;' : '&nbsp;' }}
</div>
<div class="activity text-center" :style="{ 'background-color' : 'rgba(0,0,0,'+ getLoadPercent(getAfterNoonMoment(currentDay)) }">
{{ getLoad(getAfterNoonMoment(currentDay)) > 1 ? '&#128293;' : '&nbsp;' }}
</div>
</template>
<template v-else>
<div :class="'activity text-center ' +(getLoad(getMorningMoment(currentDay)) > macroscheduleConfig.loadDevThreshold ? 'bg-dark' : 'bg-light')">&nbsp;</div>
<div :class="'activity text-center ' +(getLoad(getAfterNoonMoment(currentDay)) > macroscheduleConfig.loadDevThreshold ? 'bg-dark' : 'bg-light')">&nbsp;</div>
</template>
</td>
</template>
</tr>
</tbody>
</table>
</div>
</div>
<!--
- une Date début, et un choix matin/après-midi
- une Date Fin, et un choix matin/après-midi
- une activité (cf. liste annexe ci-dessous)
- une charge (en nombre d’hommes occupés)
- un commentaire
-->
<div ref="editModal" class="modal fade" tabindex="-1" aria-labelledby="modalLabel" aria-hidden="true" >
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalLabel" ref="modalLabel">Activité pour {{ modaleProps.name }}</h5>
</div>
<div class="modal-body">
<div class="container">
<div class="row justify-content-center">
<div v-if="error" class="alert alert-danger" role="alert">
{{ error }}
</div>
<div class="col-8">
<div class="form-group mb-3">
<label for="categorie" class="col-form-label text-md-right ms-5">Activité</label>
<select id="categorie" ref="type" class="form-select" aria-label="Sélectionnez une catégorie" :disabled="modalLoading" v-model="modaleProps.activity" required>
<option :value="undefined" disabled>Sélectionnez une activité</option>
<option v-for="activityEnum in activitiesEnum" :key="activityEnum.value" :value="activityEnum.value" :class="activityEnum.value">{{ activityEnum.label }}</option>
</select>
</div>
<div class="form-group mb-3">
<label for="start" class="col-form-label text-md-right ms-5">Date début</label>
<div class="input-group mb-3">
<input class="form-control" id="start" type="date" name="start" :value="moment(modaleProps.start).format('YYYY-MM-DD')" @input="updateDateField($event, 'start')" :disabled="modalLoading">
<select class="form-select" :disabled="modalLoading" v-model="modaleProps.startMorning" @input="updateHourField($event, 'start')">
<option value="1" :selected="moment(modaleProps.start).format('HH') === '00'">Matin</option>
<option value="0" :selected="moment(modaleProps.start).format('HH') === '12'">Midi</option>
</select>
</div>
</div>
<div class="form-group mb-3">
<label for="end" class="col-form-label text-md-right ms-5">Date fin</label>
<div class="input-group mb-3">
<input class="form-control" id="end" type="date" name="end" :value="moment(modaleProps.end).format('YYYY-MM-DD')" @input="updateDateField($event, 'end')" :disabled="modalLoading">
<select class="form-select" :disabled="modalLoading" v-model="modaleProps.endMorning" @input="updateHourField($event, 'end')">
<option value="1" :selected="moment(modaleProps.end).format('HH') === '11'">Midi</option>
<option value="0" :selected="moment(modaleProps.end).format('HH') === '23'">Soir</option>
</select>
</div>
</div>
<div class="form-group mb-3">
<label for="comment" class="col-form-label text-md-right ms-5">Commentaire</label>
<textarea ref="comment" class="form-control" name="comment" id="comment" rows="2" placeholder="" @keydown="limitLines($event)" v-model="modaleProps.comment" :disabled="modalLoading"></textarea>
</div>
<div class="form-group mb-3">
<label for="load" class="col-form-label text-md-right ms-5">Charge <i>(nombre de personnes consommées sur la période)</i></label>
<input type="number" class="form-control" name="load" id="load" v-model="modaleProps.load" :disabled="modalLoading" min="1"/>
</div>
</div>
</div>
<div class="row justify-content-center">
<div class="col-8 text-center">
<button class="btn btn-primary me-3" type="button" :disabled="modalLoading" @click="saveActivity()">
<span v-if="modalLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{{ modalLoading ? 'Chargement...' : 'Valider' }}
</button>
<button class="btn btn-danger" @click="modale.hide();$nextTick(refreshTooltips)" :disabled="modalLoading">Fermer</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Toastify from 'toastify-js'
import axios from 'axios';
export default {
props: {
isAdmin: {
type: Number,
},
dbLoadCollection: {
type: String,
},
dbMacroscheduleConfig: {
type: String,
},
dbProjects: {
type: String,
},
dbActivitiesEnum: {
type: String,
},
dbProjectStatusEnum: {
type: String,
},
today: {
type: String,
},
startDay: {
type: String,
}
},
mounted() {
let self = this
window.Toastify = Toastify
this.projects = JSON.parse(this.dbProjects)
this.loadCollection = JSON.parse(this.dbLoadCollection)
this.macroscheduleConfig = JSON.parse(this.dbMacroscheduleConfig)
this.activitiesEnum = JSON.parse(this.dbActivitiesEnum)
this.projectStatusEnum = JSON.parse(this.dbProjectStatusEnum)
this.$nextTick(this.refreshTooltips)
this.modale = new bootstrap.Modal(this.$refs.editModal)
this.$refs.editModal.addEventListener('hide.bs.modal', function (e) {
if(self.modalLoading) {
e.preventDefault()
return false
}
})
},
data() {
return {
textLimit: 254,
hoverActivityId: null,
error: '',
modaleProps: {
activity: null,
start: '',
startMorning: 1,
end: '',
endMorning: 1,
load: 0,
comment: '',
name: '',
project: null
},
currentActivity: null,
projects: [],
activitiesEnum: [],
projectStatusEnum: [],
macroscheduleConfig: [],
loadCollection: {},
modale: null,
modalLoading: false,
tooltipTriggerList: []
};
},
computed: {
todayMoment() {
return this.moment(this.today)
},
startMoment() {
return this.moment(this.startDay)
},
selectedMoment() {
return this.moment(this.startMoment.week(), 'w').hours(0).minutes(0).seconds(0).milliseconds(0).add(1, 'day').startOf('week')
},
currentMonthName() {
return this.selectedMoment.format('MMMM')
},
currentWeek() {
return this.selectedMoment.week()
},
daysToShow() {
let days = [], startMoment = this.selectedMoment.clone()
for (let i = this.macroscheduleConfig.numberOfWeeksToShow; i > 0; i--) {
for(let dayNumber = 0; dayNumber < 7; dayNumber++) {
days.push(startMoment)
startMoment = startMoment.clone().add(1, 'day')
}
}
return days
},
daysThisMonth() {
let days = this.daysToShow, currentMonth = days[0]?.month(),
out = []
for (let day of days) {
if(currentMonth === day.month() && this.dayShouldbeenSeen(day)) {
out.push(day)
}
}
return out
},
daysNextMonth() {
let days = this.daysToShow, currentMonth = days[0]?.month(),
out = []
for (let day of days) {
if(currentMonth !== day.month() && this.dayShouldbeenSeen(day)) {
out.push(day)
}
}
return out
}
},
watch: {
modaleProps: {
handler(object) {
// * Validate Date fields
this.error = ''
this.validateDateField(this.moment(this.modaleProps.start), 'start')
this.validateDateField(this.moment(this.modaleProps.end), 'end')
},
deep: true
},
currentActivity: {
handler(activityObject) {
if (activityObject.project_id) {
let project = this.getProject(activityObject.project_id)
// * Insert (or update) activity
this.$set(project.activities, activityObject.id, Object.assign({}, activityObject))
this.$nextTick(this.refreshTooltips)
this.$forceUpdate()
}
},
deep: true
}
},
methods: {
validateDateField(moment, fieldName) {
if(!this.dayShouldbeenSeen(moment)) {
this.error = 'La date de ' + (fieldName === 'start' ? 'début' : 'fin') + ' ne peut être un jour de weekend.'
return false
}
return true
},
updateDateField(e, fieldName) {
let date = this.moment(this.modaleProps[fieldName]),
time = Number.parseInt(this.modaleProps[fieldName + 'Morning']) === 1 ?
this.getMorningMoment(date, fieldName === 'start') :
this.getAfterNoonMoment(date, fieldName === 'start'),
input = this.moment(e.target.value)
if(this.validateDateField(input, fieldName)) {
this.error = ''
this.$set(this.modaleProps, fieldName, input.format('YYYY-MM-DD') + 'T' + time.format('HH:mm:ss'))
}
},
updateHourField(e, fieldName) {
let date = this.moment(this.modaleProps[fieldName])
if(Number.parseInt(e.target.value) === 1) {
this.$set(this.modaleProps, fieldName, this.getMorningMoment(date, fieldName === 'start').format('YYYY-MM-DD\THH:mm:ss'))
} else {
this.$set(this.modaleProps, fieldName, this.getAfterNoonMoment(date, fieldName === 'start').format('YYYY-MM-DD\THH:mm:ss'))
}
},
saveActivity() {
let self = this,
activity = Object.assign({}, this.currentActivity)
if(this.error.length) {
return;
}
self.modalLoading = true
activity.activity = self.modaleProps.activity
activity.start = self.modaleProps.start
activity.end = self.modaleProps.end
activity.load = self.modaleProps.load
activity.comment = self.modaleProps.comment
activity.project_id = self.modaleProps.project.id
axios.post(self.route('macroplanning.activity'), activity)
.then(response => {
self.error = ''
self.sendToast('L\'activité à été mis à jour')
self.$nextTick(() => {
self.modalLoading = false
self.modale.hide()
activity.id = response.data.id
self.$set(self, 'currentActivity', activity)
})
if(response.data.hasOverlap) {
window.location.reload()
}
}).catch(error => {
if(error.response.status === 429) {
location.reload()
}
if(error.response.status === 422) {
if(error.response.data.errors) {
self.error = ''
for(let id in error.response.data.errors) {
let field = error.response.data.errors[id]
self.error += field.join('\n') + '\n'
}
return
} else if(error.response.data.message) {
self.error = error.response.data.message
return
}
}
self.error = error
}).then(() => {
self.modalLoading = false
self.$nextTick(self.refreshTooltips)
})
},
showModale(project, activity, selectedMoment, isMorning = true) {
selectedMoment = selectedMoment.clone()
let self = this,
startMoment = (activity.start ? this.moment(activity.start) :
(isMorning ? this.getMorningMoment(selectedMoment, true) : this.getAfterNoonMoment(selectedMoment, true))),
endMoment = (activity.end ? this.moment(activity.end) :
(isMorning ? this.getMorningMoment(selectedMoment, false) : this.getAfterNoonMoment(selectedMoment, false)))
this.modaleProps = {
activity: activity.activity,
start: activity.start ?? startMoment.format('YYYY-MM-DD\THH:mm:ss'),
startMorning: startMoment.format('HH') === '00' ? 1 : 0,
end: activity.end ?? endMoment.format('YYYY-MM-DD\THH:mm:ss'),
endMorning: endMoment.format('HH') === '11' ? 1 : 0,
load: activity.load,
comment: activity.comment,
name: project.name,
project: project
}
this.currentActivity = activity
this.modale.show()
setTimeout(() => {
self.$nextTick(self.refreshTooltips)
}, 2000)
},
dayShouldbeenSeen(moment) {
return moment.day() !== 6 && moment.day() !== 0
},
projectHasMarkersForTheDay(moment, project) {
for(let marker of project.markers) {
if (moment.isSame(marker.marker, 'day')) {
return true
}
}
return false
},
setActivityHover(activity, activate = true) {
if(!activate) {
this.hoverActivityId = null
} else {
this.hoverActivityId = activity.activity !== undefined ? activity.id : ''
}
},
// * GETTERS
getLoadPercent(moment) {
let load = this.getLoad(moment)
return load > 1 ? 1 : load
},
getLoad(moment) {
return this.loadCollection[moment.format('YYYY-MM-DD 00:00:00')] ?? null
},
getActivityTooltip(activity) {
let activityEnumLabel = activity.activity !== undefined ? (this.activitiesEnum[activity.activity] ? this.activitiesEnum[activity.activity].label : '') : ''
return activity.activity !== undefined ? `${activityEnumLabel}, Charge ${activity.load} personnes, ${activity.comment}` : ''
},
getActivityClass(activity) {
return activity ? `bg-enum-${activity.activity}` : 'bg-white'
},
getProject(project_id) {
return this.projects[project_id]
},
getActivity(project, moment) {
for(let activity of Object.values(project.activities)) {
if(moment.isBetween(this.moment(activity.start, this.moment.DATETIME_LOCAL_SECONDS), this.moment(activity.end, this.moment.DATETIME_LOCAL_SECONDS), 'hour', '[]')) {
return activity
}
}
return {}
},
getMorningMoment(moment, start = true) {
moment = moment.clone()
return start ? moment.hours(0).minutes(0).seconds(0) : moment.hours(11).minutes(59).seconds(0)
},
getAfterNoonMoment(moment, start = true) {
moment = moment.clone()
return start ? moment.hours(12).minutes(0).seconds(0) : moment.hours(23).minutes(59).seconds(0)
},
getMarkersTooltip(moment, project) {
let tooltip = ''
for(let marker of project.markers) {
if (moment.isSame(marker.marker, 'day')) {
tooltip += `${this.moment(marker.marker).format('DD/MM/YYYY')} ${marker.label}<br>`
}
}
return tooltip
},
// * NAVIGATION
goPreviousWeek() {
let moment = this.startMoment ?? this.todayMoment
moment = this.moment(moment.week(), 'w')
this.showMacroScheduleDate('', moment.subtract(1, 'week'))
},
goNextWeek() {
let moment = this.startMoment ?? this.todayMoment
moment = this.moment(moment.week(), 'w')
this.showMacroScheduleDate('', moment.add(1, 'week'))
},
showMacroScheduleDate(dateString, moment = null) {
let url = new URL(window.location)
moment = moment ?? this.moment(dateString)
location = url.origin+ url.pathname + '?date='+moment.format('DD/MM/YYYY')
},
sendToast(message) {
Toastify({
text: message,
duration: 3000,
gravity: "top",
position: "right",
offset: {
x: '2rem',
y: '3rem'
},
style: {
background: "linear-gradient(to right, #00317d, #0058ca)",
}
}).showToast();
},
refreshTooltips() {
let exisitingToolips = document.querySelectorAll('.tooltip[role="tooltip"]')
for(let tooltip of exisitingToolips) {
tooltip.remove()
}
this.tooltipTriggerList = [].slice.call(this.$refs.macroschedule.querySelectorAll('[data-bs-toggle="tooltip"]'))
this.tooltipTriggerList.map((tooltipTriggerEl) => {
return new bootstrap.Tooltip(tooltipTriggerEl, { delay: 500 })
})
},
limitLines(e) {
let keynum, lines = this.$refs.comment.value.split('\n').length;
// IE
if(window.event) {
keynum = e.keyCode;
// Netscape/Firefox/Opera
} else if(e.which) {
keynum = e.which;
}
if(keynum != 8 && this.$refs.comment.value.length >= this.textLimit) {
e.preventDefault()
return false;
}
if(keynum == 13 && lines == this.$refs.comment.rows) {
e.preventDefault()
return false;
}
}
}
};
</script>
<style lang="scss" scoped>
@import './../../sass/variables';
@import './../../sass/complement';
@import "toastify-js/src/toastify.css";
.macroschedule {
@each $index, $color in $enum-activities {
.badge-activity-#{$index} {
background-color: $color;
color: a11y-color(#353535 ,$color);
}
}
.badge-marker {
background-color: rgba(255, 2, 204, 0.6);
color: a11y-color(#353535 ,rgba(255, 2, 204, 0.6));
}
.badge-today {
background-color: rgba(0, 255, 0, 0.6);
color: a11y-color(#353535 ,rgba(0, 255, 0, 0.6));
}
.badge-activity-light {
background-color: $light;
color: a11y-color(#353535 ,$light);
}
.badge-activity-dark {
background-color: $dark;
color: a11y-color(#FFF ,$dark);
}
.project {
@each $index, $color in $enum-project-status {
&.bg-enum-#{$index} {
background-color: $color;
color: a11y-color(#f6f6f6 ,$color);
}
}
}
th.today,td.today > div {
position: relative;
&:before {
content: ' ';
position: absolute;
top: 50%;
left: 0.2rem;
transform: translateY(-50%);
height: 110%;
width: 0.3rem;
background-color: rgba(0, 255, 0, 0.6);
}
}
tr:last-child > td.today {
position: relative;
&:after {
content: '↑ Aujourd\'hui';
position: absolute;
bottom: -2.5rem;
left: 0;
background-color: rgb(0, 255, 0);
padding: 0.2rem;
border: 1px solid rgba(0, 0, 0, 0.4);
border-radius: 0.2rem;
width: 7rem;
}
}
.dayCell {
width: 3rem;
}
.projectRow {
&:not(:first-child) {
border-top: 2px solid black;
}
}
.activities .activity {
&:not(:first-child) {
border-top: 1px solid black;
}
}
$buttonBgColor: #4d4d4d;
.activity {
position: relative;
.bg-white {
height: 100%;
width: 100%;
}
@each $index, $color in $enum-activities {
&.bg-enum-#{$index} {
background-color: $color;
color: a11y-color(#353535 ,$color);
height: 100%;
width: 100%;
}
}
// Reset button Style to nice one
button {
display: inline;
border: none;
padding: 0.6rem 3rem;
margin: 0;
text-decoration: none;
background: $buttonBgColor;
font-size: 1rem;
cursor: pointer;
text-align: center;
transition: background 250ms ease-in-out, transform 150ms ease;
-webkit-appearance: none;
-moz-appearance: none;
}
button:disabled,
button[disabled] {
color: #aaa;
}
button:hover,
button:focus {
background: darken($buttonBgColor, 10%);
}
button:focus {
outline: 1px solid #fff;
outline-offset: -4px;
}
button:active {
transform: scale(0.99);
}
.btn {
display: none;
position: absolute;
top: 0.1rem;
left: 0.1rem;
padding: 0;
width: 1.8rem;
height: 1.8rem;
margin: 0;
background-color: $buttonBgColor;
&:hover,
&:focus {
background-color: darken($buttonBgColor, 10%);
}
&.btn-clear {
color: darken($color: #fff, $amount: 5);
}
}
.infos {
display: none;
}
&:hover {
cursor: pointer;
}
&.hover > div {
position: relative;
z-index: 2;
background-color: rgba(0, 0, 0, 0.2);
}
& > div.marker {
z-index: 200;
position: absolute;
top: 50%;
right: 0.2rem;
transform: translateY(-50%);
height: 110%;
width: 0.7rem;
background-color: rgba(255, 2, 204, 0.6);
}
}
}
</style>

View file

@ -0,0 +1,52 @@
<template>
<div>
<v-select v-model="value" :options="labels" @search="addOption"/>
<input type="hidden" :value="inputValue" :name="name" />
</div>
</template>
<script>
export default {
mounted() {
this.labels = JSON.parse(this.dbLabels)
this.inputValue = this.dbValue
this.value = {
code: this.labels.length,
label: this.inputValue
}
for (let label of this.labels) {
label.default = true
if (label.label == this.dbValue) {
this.value = label
}
}
},
props: [
'name', 'dbValue', 'dbLabels'
],
data() {
return {
value: {},
inputValue: '',
labels: []
}
},
watch: {
value() {
this.inputValue = this.value.label
}
},
methods: {
addOption(newOption) {
if(newOption.length) {
for (let key in this.labels) {
if(!this.labels[key].default) {
this.labels.splice(key, 1)
}
}
this.labels.push({label: newOption, code: this.labels.length})
}
}
}
}
</script>

37
resources/js/modules/confirm.js vendored Normal file
View file

@ -0,0 +1,37 @@
export default {
methods: {
confirm(title, text = '', self, after, options = {}) {
if (!self) {
throw Error('self is needed to set "this" on callback');
}
if (!after) {
throw Error('callback is needed when using confirm');
}
let icon = options.icon ?? 'warning'
let confirm = options.confirm ?? 'Oui'
let cancel = options.confirm ?? 'Annuler'
Swal.fire({
title: title,
text: text,
icon: icon,
showCancelButton: true,
confirmButtonText: confirm,
cancelButtonText: cancel,
reverseButtons: true
}).then((result) => {
if (result.isConfirmed) {
if (after) {
after.apply(self, [result])
}
} else if (
/* Read more about handling dismissals below */
result.dismiss === Swal.DismissReason.cancel
) {
Swal.fire('L\'opération n\'as pas été effectuée')
}
})
return false;
}
},
}

View file

@ -0,0 +1,11 @@
<?php
return [
'classes' => [
'projects' => 'Projects',
'markers' => 'Markers',
'logs' => 'Logs',
'files' => 'Files',
'sites' => 'Sites',
]
];

View file

@ -0,0 +1,11 @@
<?php
return [
'classes' => [
'projects' => 'Projets',
'markers' => 'Marqueurs',
'logs' => 'Journaux',
'files' => 'Fichiers',
'sites' => 'Sites',
]
];

244
resources/sass/_complement.scss vendored Normal file
View file

@ -0,0 +1,244 @@
// @function strip-unit($value)
// @param {number with unit} $value
@function strip-unit($value) {
@return ($value / ($value * 0 + 1));
}
// @function pow($base, $exponents)
// Helper: Raise a number the power of another number
// Adapted from: https://gist.github.com/hail2u/1964056
//
// @param {number} $base — The base number
// @param {number} $exponents — The exponent to which to raise $base
@function pow($base, $exponents) {
$raised: 1;
@for $i from 1 through $exponents {
$raised: $raised * $base;
}
@return $raised;
}
// @function luminance($color)
// Helper: Calculate Luminance of a single color
// Adapted from: https://medium.com/dev-channel/using-sass-to-automatically-pick-text-colors-4ba7645d2796
// White luminance is 1, Black luminance is 0
// To be used in other functions or mixins — creates non-standard CSS output:
// Usage:
// .sample { luminance: luminance(#c00); }
// Output:
// .sample { luminance: 0.1283736922; }
//
@function luminance($color) {
// Thanks voxpelli for a very concise implementation of luminance measure in sass
// Adapted from: https://gist.github.com/voxpelli/6304812
$rgba: red($color), green($color), blue($color);
$rgba2: ();
@for $i from 1 through 3 {
$rgb: nth($rgba, $i);
$rgb: calc($rgb / 255);
$rgb: if($rgb < .03928, calc($rgb / 12.92), pow(calc(($rgb + .055) / 1.055), 2));
$rgba2: append($rgba2, $rgb);
}
@return (.2126 * nth($rgba2, 1) + .7152 * nth($rgba2, 2) + 0.0722 * nth($rgba2, 3))*100;
}
// @function contrast-ratio($fg, $bg)
// Helper: Calculate "readability" as defined by WCAG 2.1
// Adapted from: https://github.com/LeaVerou/contrast-ratio/blob/gh-pages/color.js
// Formula: http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
// To be used in other functions or mixins — creates non-standard CSS output:
// Usage:
// .sample { color-contrast: contrast-ratio(#c00, #fff); }
// Output:
// .sample { color-contrast: 5.89; }
//
@function contrast-ratio($fg, $bg) {
$luminance1: luminance($fg) + 0.05;
$luminance2: luminance($bg) + 0.05;
$ratio: calc($luminance1 / $luminance2);
@if $luminance2 > $luminance1 {
$ratio: calc(1 / $ratio);
}
// Round to a hundreth because 6.96 should not pass a ratio of 7.0
$ratio: calc(round($ratio * 100) / 100);
@return $ratio;
}
// @function validate-font-size($size)
// Helper: Depending on the unit recalculate a font size value into pixels if possible
// To be used in other functions or mixins — creates non-standard CSS output:
// Usage:
// .sample { validate-font-size: validate-font-size(1em); }
// Output:
// .sample { validate-font-size: 16; }
//
@function validate-font-size($size) {
@if unit($size) == 'em' or unit($size) == 'rem' or unit($size) == 'px' or unit($size) == '' {
// Check if a flexible unit
@if unit($size) == 'em' or unit($size) == 'rem' {
// Need to convert to a pixel value. Let's not overcomplicate it with possible EM inheritence scale factors
@return strip-unit($size * 16)
}
@if unit($size) == 'px' {
// We expect PX, so strip the value and return it
@return strip-unit($size);
}
@if unit($size) == '' {
@return $size;
}
} @else {
@error 'validate-font-size(): An unexpected font size unit was supplied.';
}
}
// @function get-ratio($level: 'AA', $size: 16, $bold: false)
// Helper: Determine the correct ratio value to use based on font-size and WCAG Level
// To be used in other functions or mixins — creates non-standard CSS output:
// Usage:
// .sample { get-ratio: get-ratio('AAA', 19, true); }
// Output:
// .sample { get-ratio: 4.5; }
//
@function get-ratio($level: 'AA', $size: 16, $bold: false) {
// Default ratio
$ratio: 4.5;
@if $level == 'AAA' {
$ratio: 7;
}
// Make sure the size is valid. If the value is not EM, REM, or PX (preferred), we can't help
$size: validate-font-size($size);
// Check font size
@if $size < 24 {
// Small text, use defaults
// But:
@if $size >= 19 and $bold == true {
// Special case: Small text but also bold
@if $level == 'AAA' {
$ratio: 4.5;
} @else {
$ratio: 3;
}
}
} @else {
// Larger than 24
$ratio: 3;
@if $level == 'AAA' {
$ratio: 4.5;
}
}
@return $ratio;
}
// @function light-or-dark($color)
// Helper: Use contrast against white or black to determine if a color is "light" or "dark"
// Adapted from: https://medium.com/dev-channel/using-sass-to-automatically-pick-text-colors-4ba7645d2796
// To be used in other functions or mixins — creates non-standard CSS output:
// Usage:
// .sample { light-or-dark: light-or-dark(#c00); }
// Output:
// .sample { light-or-dark: "light"; }
//
@function light-or-dark($color) {
$light-contrast: contrast-ratio($color, $white);
$dark-contrast: contrast-ratio($color, $black);
@if $light-contrast > $dark-contrast {
// Contrast against white is higher than against black, so, this is a dark color
@return "dark";
} @else {
@return "light";
}
}
// @function color-contrast($color)
// Helper: Returns black or white compared to a color, whichever produces the highest contrast
// Usage:
// .sample {
// background-color: #c00;
// color: color-contrast(#c00);
// }
// Output:
// .sample {
// background-color: #c00;
// color: #fff;
// }
//
// Named for the forthcoming CSS Module 5 spec function color-contrast();
// See https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-contrast()
//
@function color-contrast($color) {
$color-lod: light-or-dark($color);
@if ($color-lod == "dark") {
@return $white;
} @else {
@return $black;
}
}
// @function a11y-color($fg, $bg, $level: 'AA', $size: 16, $bold: false)
// Goal: Return a color that passes for the chosen WCAG level without changing the Hue of the color
// Usage:
// .sample {
// background-color: #000;
// color: a11y-color(#c0c, #000);
// }
// Output:
// .sample {
// background-color: #000;
// color: #d200d2;
// }
//
@function a11y-color($fg, $bg, $level: 'AA', $size: 16, $bold: false) {
// Helper: make sure the font size value is acceptable
$font-size: validate-font-size($size);
// Helper: With the level, font size, and bold boolean, return the proper target ratio. 3.0, 4.5, or 7.0 expected
$ratio: get-ratio($level, $font-size, $bold);
// Calculate the first contrast ratio of the given pair
$original-contrast: contrast-ratio($fg, $bg);
@if $original-contrast >= $ratio {
// If we pass the ratio already, return the original color
@return $fg;
} @else {
// Doesn't pass. Time to get to work
// Should the color be lightened or darkened?
$fg-lod: light-or-dark($fg);
$bg-lod: light-or-dark($bg);
// Set a "step" value to lighten or darken a color
// Note: Higher percentage steps means faster compile time, but we might overstep the required threshold too far with something higher than 4%
$step: 1%;
// Run through some cases where we want to darken, or use a negative step value
@if $fg-lod == 'light' and $bg-lod == 'light' {
// Both are light colors, darken the fg
$step: - $step;
} @else if $fg-lod == 'dark' and $bg-lod == 'light' {
// bg is light, fg is dark but does not pass, darken more
$step: - $step;
}
// Keeping the rest of the logic here, but our default values do not change, so this logic is not needed
//@else if $fg-lod == 'light' and $bg-lod == 'dark' {
// // bg is dark, fg is light but does not pass, lighten further
// $step: $step;
//} @else if $fg-lod == 'dark' and $bg-lod == 'dark' {
// // Both are dark, so lighten the fg
// $step: $step;
//}
// The magic happens here
// Loop through with a @while statement until the color combination passes our required ratio. Scale the color by our step value until the expression is false
// This might loop 100 times or more depending on the colors
@while contrast-ratio($fg, $bg) < $ratio {
$sat-step: 0%;
@if saturation($fg) > 10 {
$sat-step: $step;
}
$fg: scale-color($fg, $lightness: $step, $saturation:$sat-step);
}
@return $fg;
}
}

View file

@ -106,3 +106,23 @@ $modal-content-border-color: rgba($black, .1) !default;
$btn-close-color: $white !default;
$btn-close-opacity: .4 !default;
$btn-close-hover-opacity: 1 !default;
// Variables
$enum-activities: (
"0": #fff2cc,
"1": #be8e00,
"2": #a9d08e,
"3": #ff0000,
"4": #9bc2e6,
);
$enum-project-status: (
"0": #950952,
"1": #5E0035,
"2": #30011C,
"3": #020202,
"4": #023618,
"5": #014941,
"6": #005C69,
);

View file

@ -5,6 +5,7 @@
// Variables
@import 'variables';
@import 'complement';
a.col_sort {
&:link,:visited,:hover,:active {

View file

@ -10,8 +10,8 @@
@auth
<div class="collapse navbar-collapse" id="navbarSupportedContent">
@if (Auth::user()->isAdmin())
<ul class="navbar-nav me-auto">
@if (Auth::user()->isAdmin())
<li class="nav-item">
<a class="nav-link @if(strpos(request()->route()->getName(), 'ticket') === 0)active @endif" href="{{ route('ticket.index') }}">Tickets</a>
</li>
@ -27,8 +27,11 @@
<li class="nav-item">
<a class="nav-link @if(strpos(request()->route()->getName(), 'schedule') === 0)active @endif" href="{{ route('schedule.index') }}">{{ __('Planning') }}</a>
</li>
@endif
<li class="nav-item">
<a class="nav-link @if(strpos(request()->route()->getName(), 'macroplanning') === 0)active @endif" href="{{ route('macroplanning.show') }}">{{ __('Plan de charge') }}</a>
</li>
</ul>
@endif
<ul class="navbar-nav me-auto">
<li class="nav-item">
<span class="nav-link text-info fw-bold">{{ __('Tickets') }}</span>
@ -94,4 +97,4 @@
<span class="navbar-toggler-icon"></span>
</button>
</div>
</nav>
</nav>

View file

@ -0,0 +1,26 @@
@extends('layout.app')
@section('content')
<div class="container container-xxl vueComponent">
<macro-planning-component
:is-admin='{{ Auth::user()->isAdmin() ? '1' : '0' }}'
today="{{ now() }}"
start-day="{{ $date }}"
db-load-collection='@json($loadCollection)'
db-macroschedule-config='@json(config('macroschedule'))'
db-load-collection='@json($loadCollection)'
db-activities-enum='@json(ActivityEnum::toArray())'
db-project-status-enum='@json(ProjectStatus::toArray())'
db-projects='@json($projects)'/>
</div>
@endsection
@push('scriptsConstants')
<script>
window._routes = {
"macroplanning.activity": "{{ route('macroplanning.activity') }}",
};
</script>
@endpush

View file

@ -0,0 +1,9 @@
<td>
<label class="form-label">{{ $prop['label'] }}</label>
</td>
<td class="vueComponent">
<marker-label-select-component
name="{{ $fieldName }}"
db-value="{{ old($fieldName, $model->{$fieldName} ?? $prop['default'] ?? '') }}"
db-labels='@json($markerLabels->mapWithKeys(function($value, $key) { return [$key => ['code' => $key, 'label' => $value]]; }))'/>
</td>

View file

@ -1,10 +1,11 @@
@if(request()->project)
<a href="{{ route('projects.show', request()->project) }}" class="btn btn-success float-end">{{ __('Projet') }}</a>
<a href="{{ route('projects.logs.index', request()->project) }}" class="btn btn-dark float-end">{{ __('Journaux') }}</a>
<a href="{{ route('projects.files.index', request()->project) }}" class="btn btn-dark float-end">{{ __('Fichiers') }}</a>
<a href="{{ route('projects.macro_markers.index', request()->project) }}" class="btn btn-warning float-end">{{ __('models.classes.markers') }}</a>
<a href="{{ route('projects.logs.index', request()->project) }}" class="btn btn-dark float-end">{{ __('models.classes.logs') }}</a>
<a href="{{ route('projects.files.index', request()->project) }}" class="btn btn-dark float-end">{{ __('models.classes.files') }}</a>
@endif
@if(request()->site)
<a href="{{ route('sites.show', request()->site) }}" class="btn btn-success float-end">{{ __('Site') }}</a>
<a href="{{ route('sites.logs.index', request()->site) }}" class="btn btn-dark float-end">{{ __('Journaux') }}</a>
<a href="{{ route('sites.files.index', request()->site) }}" class="btn btn-dark float-end">{{ __('Fichiers') }}</a>
<a href="{{ route('sites.show', request()->site) }}" class="btn btn-success float-end">{{ __('models.classes.sites') }}</a>
<a href="{{ route('sites.logs.index', request()->site) }}" class="btn btn-dark float-end">{{ __('models.classes.logs') }}</a>
<a href="{{ route('sites.files.index', request()->site) }}" class="btn btn-dark float-end">{{ __('models.classes.files') }}</a>
@endif

View file

@ -48,6 +48,9 @@
@if($prop['type']->equals(CrudType::belongsToMany()))
@php $models->load("$fieldName"); @endphp
@endif
@if($prop['type']->equals(CrudType::hasMany()))
@php $models->load("$fieldName"); @endphp
@endif
@endforeach
{{-- DISPLAY MODELS --}}
@ -86,11 +89,14 @@
<button type="submit" class="btn btn-sm btn-danger">{{ __('crud-policies::crud.delete') }}</button>
</form>
@endcan
@can('viewAny', \App\Models\MacroSchedule\Marker::class)
<a href="{{ CrudController::getRoutePrefixed("projects.macro_markers.index", $model) }}" class="btn btn-sm btn-warning">{{ __('models.classes.markers') }}</a>
@endcan
@can('viewAny', \App\Models\Log::class)
<a href="{{ CrudController::getRoutePrefixed("projects.logs.index", $model) }}" class="btn btn-sm btn-dark">{{ __('Journaux') }}</a>
<a href="{{ CrudController::getRoutePrefixed("projects.logs.index", $model) }}" class="btn btn-sm btn-dark">{{ __('models.classes.logs') }}</a>
@endcan
@can('viewAny', \App\Models\File::class)
<a href="{{ CrudController::getRoutePrefixed("projects.files.index", $model) }}" class="btn btn-sm btn-dark">{{ __('Fichiers') }}</a>
<a href="{{ CrudController::getRoutePrefixed("projects.files.index", $model) }}" class="btn btn-sm btn-dark">{{ __('models.classes.files') }}</a>
@endcan
</div>
</td>

View file

@ -32,7 +32,7 @@
@switch($fieldName)
@case('description')
@case('todo')
<td>{!! $model->{$fieldName} !!}</td>
<td>{!! clean($model->{$fieldName}) !!}</td>
@break
@default
<td>@include('crud-policies::crud.printfield', [

View file

@ -94,10 +94,10 @@
</form>
@endcan
@can('viewAny', \App\Models\Log::class)
<a href="{{ CrudController::getRoutePrefixed("sites.logs.index", $model) }}" class="btn btn-sm btn-dark">{{ __('Journaux') }}</a>
<a href="{{ CrudController::getRoutePrefixed("sites.logs.index", $model) }}" class="btn btn-sm btn-dark">{{ __('models.classes.logs') }}</a>
@endcan
@can('viewAny', \App\Models\File::class)
<a href="{{ CrudController::getRoutePrefixed("sites.files.index", $model) }}" class="btn btn-sm btn-dark">{{ __('Fichiers') }}</a>
<a href="{{ CrudController::getRoutePrefixed("sites.files.index", $model) }}" class="btn btn-sm btn-dark">{{ __('models.classes.files') }}</a>
@endcan
</div>
</td>

View file

@ -0,0 +1,69 @@
@php
$readonly = is_callable($prop['readonly']) ? $prop['readonly']() : $prop['readonly'];
@endphp
@if($modelClass === \App\Models\MacroSchedule\Marker::class and $fieldName === 'label')
@include('markers.labelField')
@else
@switch($prop['type'])
@case(CrudType::password())
@include('crud-policies::crud.formfields.password')
@break
@case(CrudType::boolean())
@include('crud-policies::crud.formfields.boolean')
@break
@case(CrudType::int())
@include('crud-policies::crud.formfields.int')
@break
@case(CrudType::float())
@include('crud-policies::crud.formfields.float')
@break
@case(CrudType::unsignedint())
@include('crud-policies::crud.formfields.int', ['unsigned' => true])
@break
@case(CrudType::unsignedfloat())
@include('crud-policies::crud.formfields.float', ['unsigned' => true])
@break
@case(CrudType::email())
@include('crud-policies::crud.formfields.email')
@break
@case(CrudType::text())
@include('crud-policies::crud.formfields.text')
@break
@case(CrudType::date())
@include('crud-policies::crud.formfields.date')
@break
@case(CrudType::datetime())
@include('crud-policies::crud.formfields.datetime')
@break
@case(CrudType::json())
@include('crud-policies::crud.formfields.json')
@break
@case(CrudType::image())
@include('crud-policies::crud.formfields.image')
@break
@case(CrudType::file())
@include('crud-policies::crud.formfields.file')
@break
@case(CrudType::enum())
@include('crud-policies::crud.formfields.enum')
@break
@case(CrudType::order())
@include('crud-policies::crud.formfields.order')
@break
@case(CrudType::color())
@include('crud-policies::crud.formfields.color')
@break
@case(CrudType::belongsTo())
@include('crud-policies::crud.formfields.belongsTo')
@break
@case(CrudType::belongsToMany())
@include('crud-policies::crud.formfields.belongsToMany')
@break
@case(CrudType::hasMany())
@include('crud-policies::crud.formfields.hasMany')
@break
@case(CrudType::string())
@default
@include('crud-policies::crud.formfields.string')
@endswitch
@endif

View file

@ -3,6 +3,8 @@
use App\Http\Controllers\FileController;
use App\Http\Controllers\HomeController;
use App\Http\Controllers\LogController;
use App\Http\Controllers\MarkerController;
use App\Http\Controllers\ScheduleMacroController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ScheduleController;
use App\Http\Controllers\SiteController;
@ -25,21 +27,21 @@ use Illuminate\Support\Facades\Route;
Route::get('/', [HomeController::class, 'home'])->middleware('guest')->name('home');
Route::middleware(['throttle:credentials'])->group(function () {
// guest:web middleware specified in AuthController
// * guest:web middleware specified in AuthController
Auth::routes([
'login' => true,
'logout' => true,
'register' => false,
// For resetting passwords.
'reset' => true,
// for resetting passwords
// For additional password confirmations.
'confirm' => true,
// for additional password confirmations
'verify' => false,
// for email verification
// For email verification.
'verify' => false
]);
});
// Session Ping
// * Session Ping
Route::get('/ping', function () {
if (request()->isJson()) {
abort(404);
@ -49,18 +51,28 @@ Route::get('/ping', function () {
Route::middleware(['auth'])->group(function () {
// Logged user actions
// * Macro planning
Route::get('/macroplanning', [ScheduleMacroController::class, 'show'])->name('macroplanning.show');
Route::post('/activity', [ScheduleMacroController::class, 'postActivity'])->name('macroplanning.activity');
// * Logged user actions
Route::get('/users/change-password', [UserController::class, 'passwordReset'])->name('users.passwordReset');
Route::post('/users/change-password', [UserController::class, 'passwordResetSubmit'])->name('users.passwordResetSubmit');
Route::post('/users/change-password', [UserController::class, 'passwordResetSubmit'])
->name('users.passwordResetSubmit');
Route::get('/users/stats', [UserController::class, 'stats'])->name('users.stats');
// Ticket
// * Resources
Route::resource('ticket', TicketController::class)->except(['destroy']);
Route::resource('users', UserController::class);
Route::resource('sites', SiteController::class);
Route::resource('projects', ProjectController::class);
Route::resource('projects.macro_markers', MarkerController::class)
->parameters([
'macro_markers' => 'marker'
]);
// LOGS and Files
// * LOGS and Files
Route::resource('logs', LogController::class);
Route::resource('files', FileController::class);
Route::resource('projects.logs', LogController::class);
@ -68,6 +80,7 @@ Route::middleware(['auth'])->group(function () {
Route::resource('sites.logs', LogController::class);
Route::resource('sites.files', FileController::class);
// * Ticket
Route::prefix('/ticket')->name('ticket.')->group(function () {
Route::post('/{ticket}/reopen', [TicketController::class, 'reopen'])
->middleware('can:reopen,ticket')