Merge branch 'develop' into trunk

This commit is contained in:
Noah Lancien 2025-04-08 14:11:31 +02:00
commit ca7057ce77
27 changed files with 850 additions and 424 deletions

View file

@ -13,8 +13,10 @@ use App\Http\Requests\Bo\ChangeTicketStatus;
use App\Http\Requests\Bo\CreateMessage;
use App\Http\Requests\Bo\SortTickets;
use App\Http\Requests\Bo\StoreTicket;
use App\Http\Requests\Bo\UpdateChangeOrderRequest;
use App\Http\Requests\Bo\UpdateMessage;
use App\Http\Requests\Bo\UpdateTicket;
use App\Http\Requests\Bo\LinkTicket;
use App\Lib\Helpers\FileStorageHelper;
use App\Lib\Helpers\ToolboxHelper;
use App\Mail\NewTicket;
@ -29,6 +31,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Illuminate\Support\Arr;
class TicketController extends Controller
{
@ -74,7 +77,6 @@ class TicketController extends Controller
}));
} //end if
/** @var \App\Models\User */
$user = auth()->user();
$tickets = Ticket::with(['site', 'labels']);
@ -97,6 +99,10 @@ class TicketController extends Controller
$tickets = $this->filterTickets($tickets, $request);
$tickets = $this->searchTicket($tickets, $request);
/** Sort columns with a query */
$this->sortQuery($tickets);
/** Custom pagination */
$tickets = $this->customPaginate($tickets, $request->pagination);
@ -134,10 +140,13 @@ class TicketController extends Controller
abort(403);
}
$linkedTicket = $ticket->linkedTicket()->get();
$messages = $ticket->messages()->orderBy('created_at', 'DESC')->get();
$isSubscribed = (bool)$ticket->subscriptions()->where('user_id', $user->id)->count();
return view('bo.pages.tickets.show', [
'ticket' => $ticket,
'linkedTicket' => $linkedTicket,
'messages' => $messages,
'isSubscribed' => $isSubscribed
]);
@ -484,6 +493,48 @@ class TicketController extends Controller
return back()->with('info', trans('Le ticket n\'est plus assigné à personne'));
}
/**
* Link two tickets.
*
* @param LinkTicket $request
* @param Ticket $ticket
*
* @return \Illuminate\Http\RedirectResponse
*/
public function linkTicket(LinkTicket $request, Ticket $ticket)
{
$linkedTicket = Ticket::findOrFail($request->validated())->first();
if ($ticket->site_id !== $linkedTicket->site_id) {
return back()->with('error', trans('Le ticket que vous avez renseigné ne fait pas partie du même site.'));
}
if (!$ticket->linkedTicket()->where('linked_ticket_id', $linkedTicket->id)->exists()) {
$ticket->linkedTicket()->attach($linkedTicket->id);
$linkedTicket->linkedTicket()->attach($ticket->id);
return back()->with('success', trans('Les tickets ont été liés avec succès.'));
}
return back()->with('error', trans('Les tickets sont déjà liés ensemble.'));
}
/**
* Unlink two tickets.
*
* @param Ticket $ticket
* @param Ticket $linkedTicket
*
* @return \Illuminate\Http\RedirectResponse
*/
public function unlinkTicket(Ticket $ticket, Ticket $linkedTicket)
{
$ticket->linkedTicket()->detach($linkedTicket->id);
$linkedTicket->linkedTicket()->detach($ticket->id);
return back()->with('warning', trans('Le lien entre les tickets à été supprimé.'));
}
// * PRIVATES
/**
@ -502,8 +553,7 @@ class TicketController extends Controller
Session::remove('tickets.st');
Session::remove('tickets.pr');
Session::remove('tickets.ty');
Session::remove('tickets.sort_col');
Session::remove('tickets.sort_way');
Session::remove('tickets.or');
Session::remove('tickets.search');
}
if ($request->assr) {
@ -518,17 +568,15 @@ class TicketController extends Controller
if ($request->tys) {
Session::remove('tickets.ty');
}
if ($request->sort_col) {
Session::put('tickets.sort_col', $request->sort_col);
}
if ($request->sort_way) {
Session::put('tickets.sort_way', $request->sort_way);
if ($request->ord) {
Session::remove('tickets.or');
}
if ($request->pr) {
Session::put('tickets.pr', $request->pr);
}
if ($request->st) {
Session::put('tickets.st', $request->st);
Session::remove('tickets.or');
}
if ($request->ty) {
Session::put('tickets.ty', $request->ty);
@ -536,6 +584,9 @@ class TicketController extends Controller
if ($request->ass) {
Session::put('tickets.ass', $request->ass);
}
if ($request->or && Session::has('tickets.pr')) {
Session::put('tickets.or', $request->or);
}
Session::save();
if (Session::has('tickets.pr')) {
@ -556,27 +607,12 @@ class TicketController extends Controller
$tickets->where('type', Session::get('tickets.ty'));
}
if (
Session::has('tickets.sort_col') and
Session::has('tickets.sort_way') and
in_array(Session::get('tickets.sort_col'), [
'id',
'site_id',
'created_at',
'created_by',
'type',
'status',
'assigned_to'
])
) {
$tickets->orderBy(
Session::get('tickets.sort_col'),
Session::get('tickets.sort_way')
);
if (Session::has('tickets.or')) {
$tickets->orderBy('order', 'ASC');
} else {
$tickets->orderBy('last_activity', 'DESC');
}
return $tickets;
}
@ -733,4 +769,62 @@ class TicketController extends Controller
}
return \sprintf("<br><br> Fichier %d : <br>%s<br>", $fileNb, $file);
}
/**
* Change order.
*
* @param \App\Http\Requests\Bo\UpdateChangeOrderRequest $request
* @param mixed ...$params
* @return \Illuminate\Http\RedirectResponse
* @throws \RuntimeException Si le nom du parametre de la route ne correspond pas au params utilisé.
*/
public function changeOrder(UpdateChangeOrderRequest $request, mixed ...$params): \Illuminate\Http\RedirectResponse
{
$paramNames = collect(request()->route()->parameterNames);
$className = Str::of($paramNames->get($paramNames->count() - 2))->lower()->studly();
$className = "\\App\\Models\\{$className}";
$model = Arr::get($params, $paramNames->count() - 2);
if (!class_exists($className)) {
throw new \RuntimeException("The {$className} class doesn't exist.");
}
if (!\is_object($model)) {
throw new \RuntimeException("The second last param isn't an object.");
}
if (get_class($model) !== (string)Str::of($className)->replaceStart('\\', '')) {
throw new \RuntimeException("The {$className} doesn't match second last binded param.");
}
/** @var \Illuminate\Database\Eloquent\Model|null */
$model = $className::query()->where($model->getRouteKeyName(), $model->getRouteKey())->first();
if (!$model) {
return back()->with('error', trans('crud.messages.order_not_changed'));
}
return DB::transaction(function () use ($request, $className, $model) {
/** @var \Illuminate\Support\Collection $models */
$models = $className::query()
->orderBy('order', 'ASC')
->where('site_id', $model->site_id)
->where('status', '!=', TicketStatusEnum::archived)
->where('id', '!=', $model->getKey())
->where('order', $request->direction ? '<' : '>', $model->order)
->get();
/** @var \Illuminate\Database\Eloquent\Model|null $tmp */
$tmp = $request->direction ? $models->last() : $models->first();
if (!$tmp) {
return back()->with('error', trans('crud.messages.order_not_changed'));
}
$newOrder = $tmp->getOriginal('order');
$tmp->update(['order' => $model->getOriginal('order')]);
$model->update(['order' => $newOrder]);
return redirect()->back()->with('success', trans('crud.messages.order_changed'));
});
}
}

View file

@ -223,10 +223,6 @@ class Controller extends BaseController
) {
$query = $query->orderBy(\sprintf("$table.%s", request()->sort_col), request()->sort_way);
}
} elseif (Schema::hasColumn($table, 'order')) {
$query = $query->orderBy('order', 'ASC');
Session::put("$rName.sort_col", 'order');
Session::put("$rName.sort_way", 'asc');
}
return $query;
}

View file

@ -27,7 +27,7 @@ class RedirectIfAuthenticated
if (Auth::guard($guard)->check()) {
switch ($guard) {
case 'backend':
if (!$request->routeIs('bo.*')) {
if (!$request->routeIs('bo.*') && !$request->routeIs('login')) {
continue;
}
return \redirect()->route('bo.dashboard');

View file

@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Bo;
use Illuminate\Foundation\Http\FormRequest;
class LinkTicket extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize(): bool
{
return auth()->check();
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
$ticket = $this->route('ticket');
return [
'linked_ticket_id' => [
'required',
'numeric',
'exists:tickets,id',
'not_in:' . $ticket->id,
]
];
}
}

View file

@ -96,6 +96,7 @@ class StoreTicket extends FormRequest
}
}
],
'date_mep' => 'required|date',
'text' => 'required|string|min:40',
'title' => 'required|string|between:3,255',
'file_tickets' => 'sometimes|array',

View file

@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests\Bo;
use Illuminate\Foundation\Http\FormRequest;
class UpdateChangeOrderRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return boolean
*/
public function authorize(): bool
{
return auth()->check();
}
/**
* Prepare the data for validation.
*
* @return void
*/
protected function prepareForValidation(): void
{
$this->merge([
'direction' => $this->route('direction') === 'up' ? true : false
]);
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules(): array
{
return [
'direction' => 'required|boolean',
];
}
}

View file

@ -32,6 +32,8 @@ class Ticket extends Model
'site_id',
'type',
'status',
'order',
'date_mep',
'text',
'title',
'is_internal',
@ -47,6 +49,7 @@ class Ticket extends Model
'is_internal' => 'bool',
'type' => TicketTypeEnum::class,
'status' => TicketStatusEnum::class,
'date_mep' => 'date',
'last_activity' => 'timestamp',
];
@ -59,6 +62,7 @@ class Ticket extends Model
{
static::creating(function (Ticket $ticket) {
// If bug force the ticket status on new. All the others are estimateTodo.
self::setOrder($ticket);
if ($ticket->type->equals(TicketTypeEnum::bug)) {
$ticket->status = TicketStatusEnum::new;
} else {
@ -66,7 +70,7 @@ class Ticket extends Model
}
});
static::updating(function (Ticket $ticket) {
if (!$ticket->original['status'] !== $ticket->status->value) {
if (!$ticket->original['status'] !== $ticket->status->value && $ticket->original['order'] === $ticket->order) {
event(new TicketStatusChanged(
$ticket,
TicketStatusEnum::make($ticket->original['status']),
@ -85,9 +89,25 @@ class Ticket extends Model
$ticket->status->label()
)));
}
if ($ticket->status->equals(TicketStatusEnum::archived)) {
self::setOrder($ticket, true);
}
});
}
/**
* Set order after the last element of the list.
*
* @param self $model
* @param boolean $isArchived
* @return void
*/
private static function setOrder(self $model, bool $isArchived = false): void
{
$model->order = \intval(self::query()->where('status', $isArchived ? '=' : '!=', TicketStatusEnum::archived)->max('order')) + 1;
}
// * RELATIONSHIPS
/**
@ -173,6 +193,16 @@ class Ticket extends Model
return $this->belongsTo(TicketType::class, 'type', 'type');
}
/**
* BelongsToMany Linked Ticket
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function linkedTicket()
{
return $this->belongsToMany(Ticket::class, 'linked_tickets', 'ticket_id', 'linked_ticket_id');
}
// * METHODS
/**

View file

@ -67,6 +67,17 @@ class TicketPolicy
return $user->isAdmin();
}
/**
* Determine if the user can link ticket .
*
* @param \App\Models\User $user
* @return boolean
*/
public function linkTicket(User $user)
{
return $user->isAdmin();
}
/**
* Determine if the user can change the internal status of a given ticket.
*

View file

@ -0,0 +1,64 @@
<?php
namespace App\Traits\Controllers;
use App\Http\Requests\Bo\UpdateChangeOrderRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
trait ChangesModelOrder
{
/**
* Change order.
*
* @param \App\Http\Requests\Bo\UpdateChangeOrderRequest $request
* @param mixed ...$params
* @return \Illuminate\Http\RedirectResponse
* @throws \RuntimeException Si le nom du parametre de la route ne correspond pas au params utilisé.
*/
public function changeOrder(UpdateChangeOrderRequest $request, mixed ...$params): \Illuminate\Http\RedirectResponse
{
$paramNames = collect(request()->route()->parameterNames);
$className = Str::of($paramNames->get($paramNames->count() - 2))->lower()->studly();
$className = "\\App\\Models\\{$className}";
$model = Arr::get($params, $paramNames->count() - 2);
if (!class_exists($className)) {
throw new \RuntimeException("The {$className} class doesn't exist.");
}
if (!\is_object($model)) {
throw new \RuntimeException("The second last param isn't an object.");
}
if (get_class($model) !== (string)Str::of($className)->replaceStart('\\', '')) {
throw new \RuntimeException("The {$className} doesn't match second last binded param.");
}
/** @var \Illuminate\Database\Eloquent\Model|null */
$model = $className::query()->where($model->getRouteKeyName(), $model->getRouteKey())->first();
if (!$model) {
return back()->with('error', trans('crud.messages.order_not_changed'));
}
return DB::transaction(function () use ($request, $className, $model) {
/** @var \Illuminate\Database\Eloquent\Model|null */
$tmp = $className::query()
->where('order', $request->direction ? '<' : '>', $model->getOriginal('order'))
->orderBy('order', $request->direction ? 'DESC' : 'ASC')
->first();
if (!$tmp) {
return back()->with('error', trans('crud.messages.order_not_changed'));
}
$newOrder = $tmp->getOriginal('order');
$tmp->update(['order' => $model->getOriginal('order')]);
$model->update(['order' => $newOrder]);
return redirect()->back()->with('success', trans('crud.messages.order_changed'));
});
}
}

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Support\Facades\Schema;
use App\Models\Site;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->integer('order')->after('status')->comment('Ticket order.');
});
Site::query()->get()->map(function ($site) {
$site->tickets->map(function ($ticket, $ticketIndex) {
$ticket->order = $ticketIndex + 1;
$ticket->saveQuietly();
});
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->dropColumn('order');
});
}
};

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->date('date_mep')->nullable()->after('gitlab_issue')->comment('Date MEP souhaitée');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down(): void
{
Schema::table('tickets', function (Blueprint $table) {
$table->dropColumn('date_mep');
});
}
};

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::create('linked_tickets', function (Blueprint $table) {
$table->unsignedBigInteger('ticket_id')
->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('linked_ticket_id')
->constrained()->cascadeOnDelete();
$table->foreign('ticket_id')->references('id')->on('tickets');
$table->foreign('linked_ticket_id')->references('id')->on('tickets');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down(): void
{
Schema::dropIfExists('linked_tickets');
}
};

View file

@ -50,5 +50,6 @@
"Verify Your Email Address": "Verify Your Email Address",
"Whoops!": "Whoops!",
"You are logged in!": "You are logged in!",
"You are receiving this email because we received a password reset request for your account.": "You are receiving this email because we received a password reset request for your account."
}
"You are receiving this email because we received a password reset request for your account.": "You are receiving this email because we received a password reset request for your account.",
"see_mode": "see :mode mode"
}

View file

@ -214,4 +214,14 @@ return [
'username' => 'username',
'year' => 'year',
],
'custom' => [
'type' => 'type',
'tickets' => 'tickets',
'assigned_to' => 'assigned to',
'order' => 'order',
'priority' => 'priority',
'default' => 'default',
'date_mep' => 'desired MEP date',
'date_mep_empty' => 'no date entered'
]
];

View file

@ -55,5 +55,6 @@
"Verify Your Email Address": "Vérifiez votre adresse e-mail",
"Whoops!": "Oups !",
"You are logged in!": "Vous êtes connecté·e !",
"You are receiving this email because we received a password reset request for your account.": "Vous recevez cet e-mail car nous avons reçu une demande de réinitialisation de mot de passe pour votre compte."
}
"You are receiving this email because we received a password reset request for your account.": "Vous recevez cet e-mail car nous avons reçu une demande de réinitialisation de mot de passe pour votre compte.",
"see_mode": "voir mode :mode"
}

View file

@ -89,5 +89,7 @@ return [
'sort_delete' => 'Supprimer le tri',
'sort_arrow' => 'Ici, les flèches sont utilisées pour trier et non pour filtrer',
'right' => 'Vous n\'avez pas les droits',
'view_linked_ticket' => 'voir le ticket :model',
'unlink_ticket' => 'supprimer le lien avec le ticket :model'
]
];

View file

@ -1,131 +0,0 @@
<?php
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
return [
'accepted' => 'Ce champ doit être accepté.',
'active_url' => 'Ce n\'est pas une URL valide',
'after' => 'La date doit être postérieure au :date.',
'after_or_equal' => 'La date doit être postérieure ou égale au :date.',
'alpha' => 'Ce champ doit contenir uniquement des lettres',
'alpha_dash' => 'Ce champ doit contenir uniquement des lettres, des chiffres et des tirets.',
'alpha_num' => 'Ce champ doit contenir uniquement des chiffres et des lettres.',
'array' => 'Ce champ doit être un tableau.',
'attached' => 'Ce champ est déjà attaché.',
'before' => 'Ce champ doit être une date antérieure au :date.',
'before_or_equal' => 'Ce champ doit être une date antérieure ou égale au :date.',
'between' => [
'array' => 'Le tableau doit contenir entre :min et :max éléments.',
'file' => 'La taille du fichier doit être comprise entre :min et :max kilo-octets.',
'numeric' => 'La valeur doit être comprise entre :min et :max.',
'string' => 'Le texte doit contenir entre :min et :max caractères.',
],
'boolean' => 'Ce champ doit être vrai ou faux.',
'confirmed' => 'Le champ de confirmation ne correspond pas.',
'date' => 'Ce n\'est pas une date valide.',
'date_equals' => 'La date doit être égale à :date.',
'date_format' => 'Ce champ ne correspond pas au format :format.',
'different' => 'Cette valeur doit être différente de :other.',
'digits' => 'Ce champ doit contenir :digits chiffres.',
'digits_between' => 'Ce champ doit contenir entre :min et :max chiffres.',
'dimensions' => 'La taille de l\'image n\'est pas conforme.',
'distinct' => 'Ce champ a une valeur en double.',
'email' => 'Ce champ doit être une adresse email valide.',
'ends_with' => 'Ce champ doit se terminer par une des valeurs suivantes : :values',
'exists' => 'Ce champ sélectionné est invalide.',
'file' => 'Ce champ doit être un fichier.',
'filled' => 'Ce champ doit avoir une valeur.',
'gt' => [
'array' => 'Le tableau doit contenir plus de :value éléments.',
'file' => 'La taille du fichier doit être supérieure à :value kilo-octets.',
'numeric' => 'La valeur doit être supérieure à :value.',
'string' => 'Le texte doit contenir plus de :value caractères.',
],
'gte' => [
'array' => 'Le tableau doit contenir au moins :value éléments.',
'file' => 'La taille du fichier doit être supérieure ou égale à :value kilo-octets.',
'numeric' => 'La valeur doit être supérieure ou égale à :value.',
'string' => 'Le texte doit contenir au moins :value caractères.',
],
'image' => 'Ce champ doit être une image.',
'in' => 'Ce champ est invalide.',
'in_array' => 'Ce champ n\'existe pas dans :other.',
'integer' => 'Ce champ doit être un entier.',
'ip' => 'Ce champ doit être une adresse IP valide.',
'ipv4' => 'Ce champ doit être une adresse IPv4 valide.',
'ipv6' => 'Ce champ doit être une adresse IPv6 valide.',
'json' => 'Ce champ doit être un document JSON valide.',
'lt' => [
'array' => 'Le tableau doit contenir moins de :value éléments.',
'file' => 'La taille du fichier doit être inférieure à :value kilo-octets.',
'numeric' => 'La valeur doit être inférieure à :value.',
'string' => 'Le texte doit contenir moins de :value caractères.',
],
'lte' => [
'array' => 'Le tableau doit contenir au plus :value éléments.',
'file' => 'La taille du fichier doit être inférieure ou égale à :value kilo-octets.',
'numeric' => 'La valeur doit être inférieure ou égale à :value.',
'string' => 'Le texte doit contenir au plus :value caractères.',
],
'max' => [
'array' => 'Le tableau ne peut contenir plus de :max éléments.',
'file' => 'La taille du fichier ne peut pas dépasser :max kilo-octets.',
'numeric' => 'La valeur ne peut être supérieure à :max.',
'string' => 'Le texte ne peut contenir plus de :max caractères.',
],
'mimes' => 'Le fichier doit être de type : :values.',
'mimetypes' => 'Le fichier doit être de type : :values.',
'min' => [
'array' => 'Le tableau doit contenir au moins :min éléments.',
'file' => 'La taille du fichier doit être supérieure à :min kilo-octets.',
'numeric' => 'La valeur doit être supérieure ou égale à :min.',
'string' => 'Le texte doit contenir au moins :min caractères.',
],
'multiple_of' => 'La valeur doit être un multiple de :value',
'not_in' => 'Le champ sélectionné n\'est pas valide.',
'not_regex' => 'Le format du champ n\'est pas valide.',
'numeric' => 'Ce champ doit contenir un nombre.',
'password' => 'Le mot de passe est incorrect',
'present' => 'Ce champ doit être présent.',
'prohibited' => 'Ce champ est interdit',
'prohibited_if' => 'Ce champ est interdit quand :other a la valeur :value.',
'prohibited_unless' => 'Ce champ est interdit à moins que :other ait l\'une des valeurs :values.',
'regex' => 'Le format du champ est invalide.',
'relatable' => 'Ce champ n\'est sans doute pas associé avec cette donnée.',
'required' => 'Ce champ est obligatoire.',
'required_if' => 'Ce champ est obligatoire quand la valeur de :other est :value.',
'required_unless' => 'Ce champ est obligatoire sauf si :other est :values.',
'required_with' => 'Ce champ est obligatoire quand :values est présent.',
'required_with_all' => 'Ce champ est obligatoire quand :values sont présents.',
'required_without' => 'Ce champ est obligatoire quand :values n\'est pas présent.',
'required_without_all' => 'Ce champ est requis quand aucun de :values n\'est présent.',
'same' => 'Ce champ doit être identique à :other.',
'size' => [
'array' => 'Le tableau doit contenir :size éléments.',
'file' => 'La taille du fichier doit être de :size kilo-octets.',
'numeric' => 'La valeur doit être :size.',
'string' => 'Le texte doit contenir :size caractères.',
],
'starts_with' => 'Ce champ doit commencer avec une des valeurs suivantes : :values',
'string' => 'Ce champ doit être une chaîne de caractères.',
'timezone' => 'Ce champ doit être un fuseau horaire valide.',
'unique' => 'La valeur est déjà utilisée.',
'uploaded' => 'Le fichier n\'a pu être téléversé.',
'url' => 'Le format de l\'URL n\'est pas valide.',
'uuid' => 'Ce champ doit être un UUID valide',
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
'attributes' => [],
];

View file

@ -248,5 +248,16 @@ return [
'url_re7' => 'Url de RECETTE',
'username' => 'nom d\'utilisateur',
'year' => 'année',
'id' => 'identifiant'
],
'custom' => [
'type' => 'type',
'tickets' => 'étiquettes',
'assigned_to' => 'assigné à',
'order' => 'ordre',
'priority' => 'priorité',
'default' => 'défaut',
'date_mep' => 'date de MEP souhaitée',
'date_mep_empty' => 'aucune date renseignée'
]
];

View file

@ -33,6 +33,7 @@ import {
faDeleteLeft,
faDiagramProject,
faDownload,
faEquals,
faEraser,
faEye,
faEyeSlash,
@ -60,10 +61,12 @@ import {
faTicket,
faTimes,
faTrash,
faTriangleExclamation,
faUndoAlt,
faUsers,
faWandMagic,
faXmark
faXmark,
faLinkSlash
} from "@fortawesome/free-solid-svg-icons";
config.autoReplaceSvg = false;
@ -124,6 +127,7 @@ library.add(faCircleExclamation);
library.add(faCircleInfo);
library.add(faPaintRoller);
library.add(faTicket);
library.add(faLinkSlash);
// Front
library.add(faChevronLeft);
@ -137,4 +141,6 @@ library.add(faCopy);
library.add(faClipboard);
library.add(faXmark);
library.add(faArrowRightFromBracket);
library.add(faTriangleExclamation);
library.add(faEquals);
dom.watch();

View file

@ -0,0 +1,42 @@
<td class="text-center align-middle">
@if (isset($value))
{{ $value }}
@endif
@can('changeOrder', $model)
<div class="d-flex justify-content-center align-items-center">
<form @class(['invisible' => $loop->first and $models->onFirstPage()])
action="{{ route('bo.' . $routeName . '.change-order', [str($routeName)->singular()->value => $model, 'direction' => 'up']) }}"
method="POST">
@csrf
@method('PATCH')
<button data-bs-toggle="tooltip" type="submit" title="{{ __('crud.other.up') }}" @class([
'btn btn-sm btn-outline-secondary',
'disabled' => $loop->first and $models->onFirstPage(),
])>
<i class="fas fa-arrow-up"></i>
</button>
</form>
<form @class([
'invisible' =>
$loop->last and $models->currentPage() === $models->lastPage(),
])
action="{{ route('bo.' . $routeName . '.change-order', [str($routeName)->singular()->value => $model, 'direction' => 'down']) }}"
method="POST">
@csrf
@method('PATCH')
<button data-bs-toggle="tooltip" type="submit" title="{{ __('crud.other.down') }}" @class([
'btn btn-sm btn-outline-secondary ms-1',
'disabled' =>
$loop->last and $models->currentPage() === $models->lastPage(),
])>
<i class="fas fa-arrow-down"></i>
</button>
</form>
</div>
@elseif(isset($noRightsVisible) && $noRightsVisible)
<span class="text-danger" data-bs-toggle="tooltip" title="{{ __('crud.other.right') }}">
<i class="fa-solid fa-ban"></i>
</span>
@endcan
</td>

View file

@ -0,0 +1,54 @@
@php
$iArgs = ['sort_col' => $col];
if (request()->search) {
$iArgs['search'] = request()->search;
}
@endphp
{{-- NO Sort for order column --}}
@if ($col === 'order' or in_array($col, $ignore))
<th @class([
'h-100 text-center',
'd-none d-md-table-cell' =>
isset($mobileHide) and
is_array($mobileHide) and
in_array($col, $mobileHide),
]) scope="col">
<div class="d-flex justify-content-center align-items-center h-100">
<span class="text-nowrap">{{ $colname }}</span>
</div>
</th>
@else
<th @class([
'h-100 text-center',
'd-none d-md-table-cell' =>
isset($mobileHide) and
is_array($mobileHide) and
in_array($col, $mobileHide),
]) scope="col">
<div class="d-flex justify-content-center align-items-center h-100">
<span class="text-nowrap">
{{ $colname }}
</span>
@if (
!session("$routeName.sort_way") or
session("$routeName.sort_col") !== $col or
session("$routeName.sort_way") === 'desc' and session("$routeName.sort_col") === $col)
<a class="btn btn-sm btn-outline-secondary text-decoration-none ms-1" data-bs-toggle="tooltip"
href="{{ request()->fullUrlWithQuery(array_merge($iArgs, ['sort_way' => 'asc', 'rst' => null])) }}"
title="{{ __('crud.other.sort_descending', ['name' => str($colname)->lower()]) }}">
<i class="fas fa-arrow-down 2xs"></i>
</a>
@endif
@if (
!session("$routeName.sort_way") or
session("$routeName.sort_col") !== $col or
session("$routeName.sort_way") === 'asc' and session("$routeName.sort_col") === $col)
<a class="btn btn-sm btn-outline-secondary text-decoration-none ms-1"
href="{{ request()->fullUrlWithQuery(array_merge($iArgs, ['sort_way' => 'desc', 'rst' => null])) }}"data-bs-toggle="tooltip"
title="{{ __('crud.other.sort_ascending', ['name' => str($colname)->lower()]) }}">
<i class="fas fa-arrow-up 2xs"></i>
</a>
@endif
</div>
</th>
@endif

View file

@ -1,73 +1,31 @@
@php
$route = request()->route();
$routeName = $route->getName();
$rst = !is_null(request()->rst) || !Session::has("$routeName.sorted");
$noOrder = !empty($noOrder);
$noOrder = $noOrder || (Session::get("$routeName.sort_col") !== 'order' and (Session::has("$routeName.sort_col") or Session::has("$routeName.sort_way")));
$noOrder = $noOrder || !empty(request()->search);
$ignore = (isset($ignore) and is_array($ignore)) ? $ignore : [];
$route = request()->route();
$routeName = $route->getName();
$rst = !is_null(request()->rst) || !session()->has("$routeName.sorted");
$noOrder = !empty($noOrder);
$noOrder =
$noOrder ||
(session()->get("$routeName.sort_col") !== 'order' and
(session()->has("$routeName.sort_col") or session()->has("$routeName.sort_way")));
$noOrder = $noOrder || !empty(request()->search);
$ignore = (isset($ignore) and is_array($ignore)) ? $ignore : [];
@endphp
<tr class="table-col-sorter">
@foreach($cols as $col => $colname)
@php
$iArgs = ['sort_col' => $col];
if(request()->search) {
$iArgs['search'] = request()->search;
}
@endphp
{{-- NO Sort for order column --}}
@if($col === 'order' or in_array($col, $ignore))
@if($noOrder and !in_array($col, $ignore)) @continue @endif
<th scope="col" class="text-center h-100 @if(isset($mobileHide) and is_array($mobileHide) and in_array($col, $mobileHide)) d-none d-md-table-cell @endif">
<div class="d-flex justify-content-center align-items-center h-100">
<span class="text-nowrap">{{ $colname }}</span>
</div>
</th>
@continue
@endif
<th scope="col" class="text-center h-100 @if(isset($mobileHide) and is_array($mobileHide) and in_array($col, $mobileHide)) d-none d-md-table-cell @endif">
<div class="d-flex justify-content-center align-items-center h-100">
<span class="text-nowrap">
{{ $colname }}
</span>
@if(!session("$routeName.sort_way") or session("$routeName.sort_col") !== $col or (session("$routeName.sort_way") === 'desc' and session("$routeName.sort_col") === $col))
<a class="text-decoration-none ms-1"
href="{{ request()->fullUrlWithQuery(array_merge($iArgs, ['sort_way' => 'asc', 'rst' => null])) }}">
<button
class="btn btn-sm btn-outline-dark"
title="{{ __('crud.other.sort_descending', ['name' => Str::lower($colname)]) }}"
data-bs-toggle="tooltip">
<i class="fas fa-arrow-down 2xs"></i>
</button>
</a>
@endif
@if(!session("$routeName.sort_way") or session("$routeName.sort_col") !== $col or (session("$routeName.sort_way") === 'asc' and session("$routeName.sort_col") === $col))
<a class="text-decoration-none ms-1"
href="{{ request()->fullUrlWithQuery(array_merge($iArgs, ['sort_way' => 'desc', 'rst' => null])) }}">
<button
class="btn btn-sm btn-outline-dark"
title="{{ __('crud.other.sort_ascending', ['name' => Str::lower($colname)]) }}"
data-bs-toggle="tooltip">
<i class="fas fa-arrow-up 2xs"></i>
</button>
</a>
@endif
</div>
</th>
<tr class="table-col-sorter border-start-0 border-end-0 border-top-0 border-secondary border border-2">
@foreach ($cols as $col => $colname)
@include('bo.modules.table-col-sorter-cell')
@endforeach
{{-- Buttons Col --}}
<th scope="col" class="text-end">
@if(!$rst)
<a class="btn btn-sm btn-danger"
href="{{ request()->fullUrlWithQuery(['sort_col' => null, 'sort_way' => null, 'rst' => true, 'search' => null]) }}"
data-bs-toggle="tooltip"
title="{{ __('crud.other.sort_delete') }}">
<i class="fa fa-eraser"></i>
</a>
<th class="text-end" scope="col">
@if (!$rst)
<a class="btn btn-sm btn-danger" data-bs-toggle="tooltip"
href="{{ request()->fullUrlWithQuery(['sort_col' => null, 'sort_way' => null, 'rst' => true, 'search' => null]) }}"
title="{{ __('crud.other.sort_delete') }}">
<i class="fa fa-eraser"></i>
</a>
@else
<span data-bs-toggle="tooltip" data-bs-placement="top" title="{{ __('crud.other.sort_arrow') }}">
<i class="fas fa-question-circle"></i>
</span>
<span data-bs-toggle="tooltip" title="{{ __('crud.other.sort_arrow') }}">
<i class="fas fa-question-circle"></i>
</span>
@endif
</th>
</tr>

View file

@ -93,6 +93,11 @@
</div>
@endcan
<div class="form-group col">
<label class="col-form-label" for="date_mep">{{ __('Date de mise en production souhaitée') }}</label>
<input class="form-control" id="date_mep" name="date_mep" type="date" min="{{ date('Y-m-j') }}" value="{{ old('date_mep') ?? (isset($ticket) && !is_null($ticket->date_mep) ? $ticket->date_mep->format('Y-m-j') : date('Y-m-j')) }}">
</div>
</div>
<div class="form-group mb-3">

View file

@ -13,7 +13,9 @@
<div class="col">
<div class="card">
<div class="card-header">
<h2 class="d-inline">{{ __('Gestion des tickets') }} @if (Session::get('tickets.st') == \App\Enums\Tickets\TicketStatusEnum::archived->value)
<h2 class="d-inline">
{{ __('Gestion des tickets') }}
@if (Session::get('tickets.st') == \App\Enums\Tickets\TicketStatusEnum::archived->value)
(Archivés)
@endif
</h2>
@ -24,23 +26,39 @@
@can('isAdmin')
<a class="btn btn-info" href="{{ route('bo.labels.index') }}"><i class="fa-solid fa-tags"></i></a>
@endcan
<a class="btn btn-primary" href="{{ route('bo.ticket.create') }}"><i
class="fa-solid fa-square-plus"></i>&nbsp;&nbsp;{{ __('ticket') }}</a>
<a class="btn btn-primary" href="{{ route('bo.ticket.create') }}">
<i class="fa-solid fa-square-plus"></i>&nbsp;&nbsp;{{ __('ticket') }}
</a>
@if (session('tickets.pr') && session('tickets.st') != \App\Enums\Tickets\TicketStatusEnum::archived->value)
@if (session('tickets.or'))
<a class="btn btn-info float-end" href="{{ route('bo.ticket.index', ['ord' => -1]) }}">
<i class="fa-solid fa-equals"></i>&nbsp;&nbsp;{{ __('see_mode', ['mode' => __('validation.custom.default')]) }}
</a>
@else
<a class="btn btn-danger" href="{{ route('bo.ticket.index', ['or' => 1]) }}">
<i class="fa-solid fa-triangle-exclamation"></i>&nbsp;&nbsp;{{ __('see_mode', ['mode' => __('validation.custom.priority')]) }}
</a>
@endif
@endif
@if (session('tickets.st') == \App\Enums\Tickets\TicketStatusEnum::archived->value)
<a class="btn btn-success" href="{{ route('bo.ticket.index', ['sts' => -1]) }}"><i
class="fa-solid fa-inbox"></i></a>
<a class="btn btn-success" href="{{ route('bo.ticket.index', ['sts' => -1]) }}">
<i class="fa-solid fa-inbox"></i>
</a>
@else
<a class="btn btn-secondary float-end"
href="{{ route('bo.ticket.index', ['st' => \App\Enums\Tickets\TicketStatusEnum::archived]) }}"><i
class="fa-solid fa-box-archive"></i></a>
href="{{ route('bo.ticket.index', ['st' => \App\Enums\Tickets\TicketStatusEnum::archived]) }}">
<i class="fa-solid fa-box-archive"></i>
</a>
@endif
<a class="btn btn-dark" href="{{ route('bo.ticket.index', ['rst' => 1]) }}"><i
class="fa-solid fa-eraser"></i>&nbsp;{{ __(' le tri') }}</a>
<a class="btn btn-dark" href="{{ route('bo.ticket.index', ['rst' => 1]) }}">
<i class="fa-solid fa-eraser"></i>&nbsp;{{ __(' le tri') }}
</a>
</div>
<div class="float-end me-2">
<div class="input-group">
<label class="input-group-text" for="selectProject"><i
class="fa-solid fa-diagram-project"></i></label>
<label class="input-group-text" for="selectProject">
<i class="fa-solid fa-diagram-project"></i>
</label>
<select class="form-select" id="selectProject">
<option value="-1">{{ __('Tous') }}</option>
@foreach ($sites as $site)
@ -137,12 +155,12 @@
</div>
</div>
<div class="col">
<form class="w-75 float-end mt-3" id="searchTicketForm"
<form class="float-end mt-3" id="searchTicketForm"
action="{{ request()->fullUrlWithQuery(['search' => '']) }}">
<div class="input-group mb-3">
<input class="form-control" id="searchTicket" name="search" type="text"
value="{{ session()->get('tickets.search') }}" aria-label="{{ __('Recherche de ticket..') }}"
aria-describedby="searchTicket" placeholder="{{ __('Recherche de ticket..') }}">
value="{{ session()->get('tickets.search') }}" aria-label="{{ __('Recherche de ticket...') }}"
aria-describedby="searchTicket" placeholder="{{ __('Recherche de ticket...') }}">
<button class="btn btn-outline-danger" id="searchDelete" type="button"><i
class="fa-solid fa-delete-left"></i></button>
<button class="btn btn-outline-primary" type="submit"><i
@ -162,184 +180,207 @@
</div>
</div>
<div class="card-body">
<table class="table-hover table">
<thead>
<tr>
<th scope="col">
<span>#&nbsp;</span>
<span>@include('modules.sorter', ['colname' => 'id'])</span>
</th>
<th scope="col">
<span>{{ __('Titre') }}&nbsp;</span>
<span>@include('modules.sorter', ['colname' => 'title'])</span>
</th>
<th scope="col">
<span>{{ __('Type') }}&nbsp;</span>
<span>@include('modules.sorter', ['colname' => 'type'])</span>
</th>
@can('assignLabels', App\Models\Ticket::class)
<th scope="col">{{ __('Étiquettes') }}</th>
@endcan
@if (Auth::user()->isAdmin())
<th scope="col">
<span>{{ __('Assigné à') }}&nbsp;</span>
<span>@include('modules.sorter', ['colname' => 'assigned_to'])</span>
</th>
@endif
<th scope="col">
<span>{{ __('Le') }}&nbsp;</span>
<span>@include('modules.sorter', ['colname' => 'created_at'])</span>
</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
@if (!count($tickets))
<tr>
<td class="text-center" colspan="@if (Auth::user()->isAdmin()) 6 @else 5 @endif">
<h2>{{ __('Aucun ticket') }}</h2>
</td>
</tr>
@endif
@foreach ($tickets as $ticket)
<tr class="position-relative">
<td scope="row">
<div class="d-flex justify-content-between">
<span class="me-1">{{ $ticket->id }}&nbsp;&nbsp;@if ($ticket->is_internal)
<i class="fa-solid fa-lock"></i>
@endif
</span>
<span class="d-flex flex-column text-end">
@if (Auth::user()->isAdmin())
<a href="{{ route('bo.sites.show', $ticket->site) }}"
title="{{ __('Voir le site :sitename', [
'sitename' => $ticket->site->name,
]) }}">{{ $ticket->site->name }}</a>
@else
<span class="text-decoration-underline">{{ $ticket->site->name }}</span>
@endif
<span
class="{{ $ticket->status->class() }} rounded text-center">{{ $ticket->status->label() }}</span>
</span>
</div>
</td>
<td class="text-center">
<span class="me-1">{{ $ticket->title }}</span>
</td>
<td class="text-center">
<span class="badge {{ $ticket->type->class() }}">{{ $ticket->type->label() }}</span>
</td>
@can('assignLabels', App\Models\Ticket::class)
<td>
<span class="ticket-index-labels">
@foreach ($ticket->labels as $label)
<span class="badge label colorize"
data-color="{{ $label->color }}">{{ $label->name }}</span>
@endforeach
</span>
<div class="overflow-scroll w-100">
<table class="table-hover table">
<thead>
@php
$cols = [];
if (session('tickets.or')) {
$cols = array_merge($cols, [
'order' => Str::ucfirst(__('validation.custom.priority'))
]);
}
$cols = array_merge($cols, [
'id' => Str::ucfirst(__('validation.attributes.id')),
'title' => Str::ucfirst(__('validation.attributes.name')),
'type' => Str::ucfirst(__('validation.custom.type')),
]);
if (Auth::user()->isAdmin()) {
$cols = array_merge($cols, [
'tickets' => Str::ucfirst(__('validation.custom.tickets')),
'assigned_to' => Str::ucfirst(__('validation.custom.assigned_to')),
]);
}
$cols = array_merge($cols, [
'date_mep' => Str::ucfirst(__('validation.custom.date_mep')),
'created_at' => Str::ucfirst(__('validation.attributes.created_at')),
]);
@endphp
@include('bo.modules.table-col-sorter', [
'cols' => $cols,
'ignore' => ['id', 'tickets'],
])
</thead>
<tbody>
@if (!count($tickets))
<tr>
<td class="text-center" colspan="@if (Auth::user()->isAdmin()) 6 @else 5 @endif">
<h2>{{ __('Aucun ticket') }}</h2>
</td>
@endcan
@if (Auth::user()->isAdmin())
<td>{{ $ticket->assignedUser ? $ticket->assignedUser->name : __('Personne') }}</td>
@endif
<td>{{ Carbon\Carbon::parse($ticket->created_at)->format('d/m/Y à H:i') }}</td>
<td>
<div class="d-flex flex-column">
<div>
<div class="btn-group" role="group">
@php
$ticket_files = $ticket->getFiles();
@endphp
@foreach ($ticket_files as $key => $file)
@if ($file !== null && (\App\Lib\Helpers\ToolboxHelper::isImageFile($file) || \App\Lib\Helpers\ToolboxHelper::isPdfFile($file)))
<a class="glightbox btn btn-outline-dark"
data-gallery="gallery{{ $ticket->id }}" href="{{ asset($file) }}"
target="_blank"><i class="fa-solid fa-file-image"></i>
</a>
@break
</tr>
@endif
@foreach ($tickets as $key => $ticket)
<tr class="position-relative">
{{-- order --}}
@if (session('tickets.or'))
@include('bo.modules.change-model-order', [
'routeName' => 'ticket',
'model' => $ticket,
'models' => $tickets,
'value' => $key + 1,
'noRightsVisible' => false,
])
@endif
{{-- id --}}
<td scope="row">
<div class="d-flex justify-content-between">
<span class="me-1">{{ $ticket->id }}&nbsp;&nbsp;@if ($ticket->is_internal)
<i class="fa-solid fa-lock"></i>
@endif
@endforeach
{{-- Modal du ticket --}}
<!-- Button trigger modal -->
<button class="btn btn-outline-info" data-bs-toggle="modal"
data-bs-target="#ticket{{ $ticket->id }}Modal" type="button"><i
class="fa-solid fa-eye"></i></button>
<a class="btn btn-outline-success"
href="{{ route('bo.ticket.show', $ticket->id) }}"><i
class="fa-solid fa-comment"></i></a>
@if (auth()->user()->isAdmin())
<a class="btn btn-outline-primary"
href="{{ route('bo.ticket.edit', $ticket) }}"><i
class="fa-solid fa-pen"></i></a>
@endif
@can('changeStatus', $ticket)
@if (!$ticket->status->equals(\App\Enums\Tickets\TicketStatusEnum::archived))
<form class="confirmJS" method="POST"
action="{{ route('bo.ticket.archive', $ticket) }}">
@method('PUT')
@csrf
<button class="btn btn-outline-danger rounded-0 rounded-end" type="submit"><i
class="fa-solid fa-box-archive"></i></button>
</form>
</span>
<span class="d-flex flex-column text-end">
@if (Auth::user()->isAdmin())
<a href="{{ route('bo.sites.show', $ticket->site) }}"
title="{{ __('Voir le site :sitename', [
'sitename' => $ticket->site->name,
]) }}">{{ $ticket->site->name }}</a>
@else
<span class="text-decoration-underline">{{ $ticket->site->name }}</span>
@endif
@endcan
<span class="{{ $ticket->status->class() }} rounded text-center">
{{ $ticket->status->label() }}
</span>
</span>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="ticket{{ $ticket->id }}Modal"
aria-labelledby="ticket{{ $ticket->id }}ModalLabel" aria-hidden="true" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ticket{{ $ticket->id }}ModalLabel">
@can('isAdmin')
<a href="{{ route('bo.sites.show', $ticket->site) }}"
title="{{ __('Voir le site :sitename', [
'sitename' => $ticket->site->name,
]) }}"><b>{{ $ticket->site->name }}</b></a>
@else
<b>{{ $ticket->site->name }}</b>
@endcan -
<u>
{{ __('Ticket n°') }} {{ $ticket->id }}
@if ($ticket->is_internal)
<i class="fa-solid fa-lock"></i>
@endif
</u>&nbsp;-&nbsp;
<span class="badge {{ $ticket->type->class() }}">
{{ $ticket->type->label() }}
</span>
</h5>
<button class="btn btn-sm btn-primary bg-primary" data-bs-dismiss="modal"
type="button" aria-label="Close">
<i class="fa-solid fa-xmark text-white"></i>
</button>
</div>
<div class="modal-body">
@can('assignLabels', App\Models\Ticket::class)
<div class="mb-3 mt-1">
@foreach ($ticket->labels as $label)
<span class="badge label colorize"
data-color="{{ $label->color }}">{{ $label->name }}</span>
@endforeach
</div>
</td>
{{-- title --}}
<td class="text-center">
<span class="me-1">{{ $ticket->title }}</span>
</td>
{{-- type --}}
<td class="text-center">
<span class="badge {{ $ticket->type->class() }}">{{ $ticket->type->label() }}</span>
</td>
{{-- tag --}}
@php
$routeName = request()->route()->getName();
@endphp
@can('assignLabels', App\Models\Ticket::class)
<td>
<span class="ticket-index-labels">
@foreach ($ticket->labels as $label)
<span class="badge label colorize"
data-color="{{ $label->color }}">{{ $label->name }}</span>
@endforeach
</span>
</td>
@endcan
{{-- assigned_to / date_mep --}}
@if (Auth::user()->isAdmin())
<td>{{ $ticket->assignedUser ? $ticket->assignedUser->name : __('Personne') }}</td>
@endif
<td class="text-center">
{{ is_null($ticket->date_mep) ? Str::ucfirst(__("validation.custom.date_mep_empty")) : $ticket->date_mep->format('d/m/Y') }}
</td>
<td>{{ Carbon\Carbon::parse($ticket->created_at)->format('d/m/Y à H:i') }}</td>
<td>
<div class="d-flex flex-column">
<div>
<div class="btn-group" role="group">
@php
$ticket_files = $ticket->getFiles();
@endphp
@foreach ($ticket_files as $key => $file)
@if ($file !== null && (\App\Lib\Helpers\ToolboxHelper::isImageFile($file) || \App\Lib\Helpers\ToolboxHelper::isPdfFile($file)))
<a class="glightbox btn btn-outline-dark"
data-gallery="gallery{{ $ticket->id }}" href="{{ asset($file) }}"
target="_blank"><i class="fa-solid fa-file-image"></i>
</a>
@break
@endif
@endforeach
{{-- Modal du ticket --}}
<!-- Button trigger modal -->
<button class="btn btn-outline-info" data-bs-toggle="modal"
data-bs-target="#ticket{{ $ticket->id }}Modal" type="button"><i
class="fa-solid fa-eye"></i></button>
<a class="btn btn-outline-success"
href="{{ route('bo.ticket.show', $ticket->id) }}"><i
class="fa-solid fa-comment"></i></a>
@if (auth()->user()->isAdmin())
<a class="btn btn-outline-primary"
href="{{ route('bo.ticket.edit', $ticket) }}"><i
class="fa-solid fa-pen"></i></a>
@endif
@can('changeStatus', $ticket)
@if (!$ticket->status->equals(\App\Enums\Tickets\TicketStatusEnum::archived))
<form class="confirmJS" method="POST"
action="{{ route('bo.ticket.archive', $ticket) }}">
@method('PUT')
@csrf
<button class="btn btn-outline-danger rounded-0 rounded-end" type="submit"><i
class="fa-solid fa-box-archive"></i></button>
</form>
@endif
@endcan
<p>{!! clean(nl2br($ticket->text)) !!}</p>
</div>
<div class="modal-footer">
<button class="btn btn-sm btn-primary" data-bs-dismiss="modal"
type="button">{{ __('Fermer') }}</button>
</div>
</div>
</div>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
{{ $tickets->withQueryString()->links() }}
<!-- Modal -->
<div class="modal fade" id="ticket{{ $ticket->id }}Modal"
aria-labelledby="ticket{{ $ticket->id }}ModalLabel" aria-hidden="true" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ticket{{ $ticket->id }}ModalLabel">
@can('isAdmin')
<a href="{{ route('bo.sites.show', $ticket->site) }}"
title="{{ __('Voir le site :sitename', [
'sitename' => $ticket->site->name,
]) }}"><b>{{ $ticket->site->name }}</b></a>
@else
<b>{{ $ticket->site->name }}</b>
@endcan -
<u>
{{ __('Ticket n°') }} {{ $ticket->id }}
@if ($ticket->is_internal)
<i class="fa-solid fa-lock"></i>
@endif
</u>&nbsp;-&nbsp;
<span class="badge {{ $ticket->type->class() }}">
{{ $ticket->type->label() }}
</span>
</h5>
<button class="btn btn-sm btn-primary bg-primary" data-bs-dismiss="modal"
type="button" aria-label="Close">
<i class="fa-solid fa-xmark text-white"></i>
</button>
</div>
<div class="modal-body">
@can('assignLabels', App\Models\Ticket::class)
<div class="mb-3 mt-1">
@foreach ($ticket->labels as $label)
<span class="badge label colorize"
data-color="{{ $label->color }}">{{ $label->name }}</span>
@endforeach
</div>
@endcan
<p>{!! clean(nl2br($ticket->text)) !!}</p>
</div>
<div class="modal-footer">
<button class="btn btn-sm btn-primary" data-bs-dismiss="modal"
type="button">{{ __('Fermer') }}</button>
</div>
</div>
</div>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{ $tickets->withQueryString()->links() }}
</div>
</div>
</div>
</div>

View file

@ -273,6 +273,46 @@
</div>
@endif
<hr>
<div class="flex-col">
<div>
<h4>{{ __('Tickets liés') }}</h4>
</div>
@can('linkTicket', $ticket)
<div class="row">
<form class="col" id="form_link_ticket" action="{{ route('bo.ticket.link', ['ticket' => $ticket]) }}" method="POST">
@csrf
<div class="input-group">
<label class="input-group-text" for="link-ticket"><i class="fa-solid fa-ticket"></i></label>
<input class="form-control" id="linked_ticket_id" name="linked_ticket_id" type="text" placeholder="Numéro du ticket">
<button class="btn btn-sm btn-success float-end" type="submit">
{{ __('Lier') }}
</button>
</div>
</form>
</div>
@endcan
<div class="row row-cols-auto gap-2">
@foreach ($linkedTicket as $linked)
<div class="col">
@can('linkTicket', $ticket)
<form id="form_unlink_ticket" action="{{ route('bo.ticket.unlink', ['ticket' => $ticket, 'linkedTicket' => $linked]) }}" method="POST">
@csrf
@method('DELETE')
<div class="input-group mt-2">
@endcan
<a class="btn btn-xl btn-outline-primary" href="{{ route('bo.ticket.show', $linked->id) }}" title="{{ Str::ucfirst(__('crud.other.view_linked_ticket', ['model' => $linked->id])) }}">
TICKET - {{ $linked->id }}
</a>
@can('linkTicket', $ticket)
<button class="btn btn-xl btn-outline-danger" type="submit" title="{{ Str::ucfirst(__('crud.other.unlink_ticket', ['model' => $linked->id])) }}"><i class="fa-solid fa-link-slash"></i></button>
</div>
</form>
@endcan
</div>
@endforeach
</div>
</div>
<hr>
@can('createGitlabIssue', $ticket)
<form class="col d-flex justify-content-end" id="form_issue" action="{{ route('bo.ticket.create-issue', $ticket) }}" method="POST">
@csrf

View file

@ -1,6 +0,0 @@
@if(!session('tickets.sort_way') or session('tickets.sort_col') !== $colname or (session('tickets.sort_way') === 'desc' and session('tickets.sort_col') === $colname))
<a class="col_sort text-secondary lead asc" href="{{ request()->fullUrlWithQuery(['sort_col' => $colname, 'sort_way' => 'asc']) }}">&nbsp;&darr;&nbsp;</a>
@endif
@if(!session('tickets.sort_way') or session('tickets.sort_col') !== $colname or (session('tickets.sort_way') === 'asc' and session('tickets.sort_col') === $colname))
<a class="col_sort text-secondary lead desc" href="{{ request()->fullUrlWithQuery(['sort_col' => $colname, 'sort_way' => 'desc']) }}">&nbsp;&uarr;&nbsp;</a>
@endif

View file

@ -95,6 +95,10 @@ Route::middleware('admin-secure')
)
->middleware('can:createGitlabIssue,ticket')
->name('ticket.create-issue');
Route::patch('/tickets/{ticket}/change-order/{direction}', [
TicketController::class,
'changeOrder'
])->where('direction', 'up|down')->name('ticket.change-order');
});
Route::resource('credit_accounts', AccountController::class)
->only(['index'])
@ -150,6 +154,12 @@ Route::middleware('admin-secure')
Route::post('/{ticket}/subscribe', [TicketController::class, 'subscribe'])
->middleware('can:suscribeToTicket,ticket')
->name('subscribe');
Route::post('/{ticket}/link', [TicketController::class, 'linkTicket'])
->middleware('can:linkTicket,ticket')
->name('link');
Route::delete('/{ticket}/unlink/{linkedTicket}', [TicketController::class, 'unlinkTicket'])
->middleware('can:linkTicket,ticket')
->name('unlink');
Route::put('/{ticket}/archive', [TicketController::class, 'setToArchive'])
->middleware('can:changeStatus,ticket')
->name('archive');