diff --git a/app/Http/Controllers/Back/SiteController.php b/app/Http/Controllers/Back/SiteController.php new file mode 100644 index 0000000..9b610ac --- /dev/null +++ b/app/Http/Controllers/Back/SiteController.php @@ -0,0 +1,295 @@ + + */ + private \Illuminate\Support\Collection $userModelsProjectManager; + + /** + * Project managers collection. + * + * @var \Illuminate\Support\Collection + */ + private \Illuminate\Support\Collection $userModelsClient; + + /** + * Gitlab projects collection. + * + * @var \Illuminate\Support\Collection + */ + private \Illuminate\Support\Collection $gitlabProjectModels; + + /** + * Create the controller instance. + */ + public function __construct() + { + $this->authorizeResource(Site::class); + $this->modelName = Site::modelName(); + $this->modelPage = Site::modelPage(); + $userModels = User::getModelsCached() + ->where("published", true) + ->where('deleted_at', null) + ->values(); + $this->userModelsProjectManager = $userModels + ->filter(fn(User $user) => $user->role->value === UserRoleRegistry::get('project-manager')->value) + ->values(); + $this->userModelsClient = $userModels + ->filter(fn(User $user) => $user->role->value === UserRoleRegistry::get('client')->value) + ->values(); + $this->gitlabProjectModels = GitlabProject::getModelsCached(); + } + + /** + * Display a listing of the resource. + * + * @param \Illuminate\Http\Request $request + * @return \Inertia\Response + */ + public function index(Request $request): \Inertia\Response + { + /** @var \Illuminate\Database\Eloquent\Builder<\App\Models\Site> $query */ + $query = Site::query()->with(['projectManager', 'gitlabProject']); + ; + + if ($request->routeIs("back.$this->modelName.trash")) { + $query = $query->onlyTrashed(); + } + + /** @var string $search Search field */ + $search = $request->search; + if ($search) { + $this->searchQuery( + $query, + $search, + null, + 'name', + ); + } + $searchFields = \implode(', ', [ + trans('validation.attributes.name'), + ]); + + /** Sort columns with a query */ + $this->sortQuery($query); + + /** Custom pagination */ + $models = $this->paginate($query); + + return $this->inertiaRender("back/Pages/$this->modelPage/Index", [ + 'models' => CommonResource::collection($models), + 'search' => $search, + 'searchFields' => $searchFields, + ]); + } + + /** + * Show the form for creating a new resource. + * + * @param \App\Models\Site $site + * @return \Inertia\Response + */ + public function create(Site $site): \Inertia\Response + { + return $this->inertiaRender("back/Pages/$this->modelPage/Create", [ + 'data' => CommonResource::make($site), + 'userModelsProjectManager' => $this->userModelsProjectManager, + 'userModelsClient' => $this->userModelsClient, + 'gitlabProjectModels' => $this->gitlabProjectModels, + ]); + } + + /** + * Show a specific resource. + * + * @param \App\Models\Site $site + * @return \Inertia\Response + */ + public function show(Site $site): \Inertia\Response + { + return $this->inertiaRender("back/Pages/$this->modelPage/Show", [ + 'data' => CommonResource::make($site->load(['projectManager', 'gitlabProject', 'users'])), + 'userModelsProjectManager' => $this->userModelsProjectManager, + 'userModelsClient' => $this->userModelsClient, + 'gitlabProjectModels' => $this->gitlabProjectModels, + ]); + } + + /** + * Store a newly created resource. + * + * @param \App\Http\Requests\Back\Sites\StoreSiteRequest $request + * @return \Illuminate\Http\RedirectResponse + */ + public function store(StoreSiteRequest $request): \Illuminate\Http\RedirectResponse + { + return DB::transaction(function () use ($request) { + $validatedData = $request->validated(); + $site = new Site(); + $site->fill(Arr::except($validatedData, 'users')); + + if ($site->saveOrFail()) { + $site->users()->syncWithoutDetaching(Arr::get($validatedData, 'users')); + return redirect()->route("back.$this->modelName.edit", $site) + ->with('success', trans('uwil-backend::crud.messages.has_been_created', [ + 'model' => Str::ucfirst(trans_choice("models.classes.$this->modelName", 1)), + ])); + } + return redirect()->back() + ->with('error', trans('uwil-backend::crud.messages.cannot_be_created', [ + 'model' => Str::ucfirst(trans_choice("models.classes.$this->modelName", 1)), + ])); + }); + } + + /** + * Show the form for editing the specified resource. + * + * @param \App\Models\Site $site + * @return \Inertia\Response + */ + public function edit(Site $site): \Inertia\Response + { + return $this->inertiaRender("back/Pages/$this->modelPage/Edit", [ + 'data' => CommonResource::make($site->load(['projectManager', 'gitlabProject', 'users'])), + 'userModelsProjectManager' => $this->userModelsProjectManager, + 'userModelsClient' => $this->userModelsClient, + 'gitlabProjectModels' => $this->gitlabProjectModels, + ]); + } + + /** + * Update the specified resource. + * + * @param \App\Http\Requests\Back\Sites\UpdateSiteRequest $request + * @param \App\Models\Site $site + * @return \Illuminate\Http\RedirectResponse + */ + public function update( + UpdateSiteRequest $request, + Site $site + ): \Illuminate\Http\RedirectResponse { + return DB::transaction(function () use ($request, $site) { + $validatedData = $request->validated(); + $site->fill(Arr::except($validatedData, 'users')); + + if ($site->saveOrFail()) { + $site->users()->sync(Arr::get($validatedData, 'users')); + return redirect()->route("back.$this->modelName.edit", $site) + ->with('success', trans('uwil-backend::crud.messages.has_been_updated', [ + 'model' => Str::ucfirst(trans_choice("models.classes.$this->modelName", 1)), + ])); + } + return redirect()->route("back.$this->modelName.edit", $site) + ->with('error', trans('uwil-backend::crud.messages.cannot_be_updated', [ + 'model' => Str::ucfirst(trans_choice("models.classes.$this->modelName", 1)), + ])); + }); + } + + /** + * Duplicate the specified resource. + * + * @param \App\Models\Site $site + * @return \Inertia\Response + */ + public function duplicate(Site $site): \Inertia\Response + { + return $this->create($site->replicate()); + } + + /** + * Restore the specified resource. + * + * @param integer $site + * @return \Illuminate\Http\RedirectResponse + */ + public function restore(int $site): \Illuminate\Http\RedirectResponse + { + $site = Site::withTrashed()->findOrFail($site); + /** @var array */ + $previousQueries = []; + \parse_str(\parse_url(url()->previous(), \PHP_URL_QUERY), $previousQueries); + if ($site->restore()) { + return redirect()->route("back.$this->modelName.trash", $previousQueries) + ->with('success', trans('uwil-backend::crud.messages.has_been_restored', [ + 'model' => Str::ucfirst(trans_choice("models.classes.$this->modelName", 1)), + ])); + } + return redirect()->back() + ->with('error', trans('uwil-backend::crud.messages.cannot_be_restored', [ + 'model' => Str::ucfirst(trans_choice("models.classes.$this->modelName", 1)), + ])); + } + + /** + * Destroy the specified resource. + * + * @param \App\Models\Site $site + * @return \Illuminate\Http\RedirectResponse + */ + public function destroy(Site $site): \Illuminate\Http\RedirectResponse + { + /** @var array */ + $previousQueries = []; + \parse_str(\parse_url(url()->previous(), \PHP_URL_QUERY), $previousQueries); + if ($site->delete()) { + return redirect()->route("back.$this->modelName.index", $previousQueries) + ->with('success', trans('uwil-backend::crud.messages.has_been_deleted', [ + 'model' => Str::ucfirst(trans_choice("models.classes.$this->modelName", 1)), + ])); + } + return redirect()->back() + ->with('error', trans('uwil-backend::crud.messages.cannot_be_deleted', [ + 'model' => Str::ucfirst(trans_choice("models.classes.$this->modelName", 1)), + ])); + } + + /** + * Destroy the specified resource. + * + * @param \App\Models\Site|integer $site + * @return \Illuminate\Http\RedirectResponse + */ + public function forceDestroy(Site|int $site): \Illuminate\Http\RedirectResponse + { + if (is_int($site)) { + $site = Site::withTrashed()->findOrFail($site); + } + /** @var array */ + $previousQueries = []; + \parse_str(\parse_url(url()->previous(), \PHP_URL_QUERY), $previousQueries); + if ($site->forceDelete()) { + return redirect()->route("back.$this->modelName.trash", $previousQueries) + ->with('success', trans('uwil-backend::crud.messages.has_been_deleted', [ + 'model' => Str::ucfirst(trans_choice("models.classes.$this->modelName", 1)), + ])); + } + return redirect()->back() + ->with('error', trans('uwil-backend::crud.messages.cannot_be_deleted', [ + 'model' => Str::ucfirst(trans_choice("models.classes.$this->modelName", 1)), + ])); + } +} diff --git a/app/Http/Middlewares/HandleInertiaRequests.php b/app/Http/Middlewares/HandleInertiaRequests.php index f089b44..b5d73f1 100644 --- a/app/Http/Middlewares/HandleInertiaRequests.php +++ b/app/Http/Middlewares/HandleInertiaRequests.php @@ -7,6 +7,7 @@ use App\Models\GitlabProject; use App\Models\InvoiceCompany; use App\Models\InvoicePackage; use App\Models\Notice; +use App\Models\Site; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; use Inertia\Middleware; @@ -47,6 +48,11 @@ class HandleInertiaRequests extends Middleware 'viewTrash' => Gate::allows('viewTrash', GitlabProject::class), 'create' => Gate::allows('create', GitlabProject::class), ], + 'sites' => [ + 'viewAny' => Gate::allows('viewAny', Site::class), + 'viewTrash' => Gate::allows('viewTrash', Site::class), + 'create' => Gate::allows('create', Site::class), + ], ], ], 'noticeTypeEnum' => NoticeTypeEnum::toArray(), diff --git a/app/Http/Requests/Back/Sites/StoreSiteRequest.php b/app/Http/Requests/Back/Sites/StoreSiteRequest.php new file mode 100644 index 0000000..51ee9ce --- /dev/null +++ b/app/Http/Requests/Back/Sites/StoreSiteRequest.php @@ -0,0 +1,76 @@ +merge([ + 'project_manager_id' => is_array($this->project_manager_id) + ? intval($this->project_manager_id[0]) ?? null + : $this->project_manager_id, + 'gitlab_project_id' => is_array($this->gitlab_project_id) + ? intval($this->gitlab_project_id[0]) ?? null + : $this->gitlab_project_id, + 'users' => is_array($this->users) ? array_map('intval', $this->users) : $this->users, + ]); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules(): array + { + return [ + 'name' => 'required|string|between:3,255|unique:sites,name', + 'url_re7' => 'nullable|string|url:http,https|unique:sites,url_re7', + 'url_prod' => 'nullable|string|url:http,https|unique:sites,url_prod', + 'project_manager_id' => 'required|integer|exists:users,id', + 'gitlab_project_id' => 'required|integer|exists:gitlab_projects,id|unique:sites,gitlab_project_id', + 'users' => 'nullable|array', + 'users.*' => 'integer|exists:users,id|distinct', + ]; + } + + /** + * Get custom attributes for validator errors. + * + * @return array + */ + public function attributes(): array + { + return [ + 'name' => trans('validation.custom.name'), + 'url_re7' => trans('validation.custom.url_re7'), + 'url_prod' => trans('validation.custom.url_prod'), + 'project_manager_id' => trans('validation.custom.project_manager_id'), + 'gitlab_project_id' => trans_choice('models.classes.gitlab-projects', 1), + 'users' => trans_choice('uwil-backend::models.classes.users', \INF), + ]; + } +} diff --git a/app/Http/Requests/Back/Sites/UpdateSiteRequest.php b/app/Http/Requests/Back/Sites/UpdateSiteRequest.php new file mode 100644 index 0000000..35caf5b --- /dev/null +++ b/app/Http/Requests/Back/Sites/UpdateSiteRequest.php @@ -0,0 +1,61 @@ +route('site')); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + #[\Override] + public function rules(): array + { + /** @var \App\Models\Site $site */ + $site = $this->route('site'); + return array_merge(parent::rules(), [ + 'name' => [ + 'required', + 'string', + 'between:3,255', + Rule::unique('sites', 'name')->ignore($site->getRouteKey()), + ], + 'url_re7' => [ + 'required', + 'string', + 'url:http,https', + Rule::unique('sites', 'url_re7')->ignore($site->getRouteKey()), + ], + 'url_prod' => [ + 'required', + 'string', + 'url:http,https', + Rule::unique('sites', 'url_prod')->ignore($site->getRouteKey()), + ], + 'gitlab_project_id' => [ + 'required', + 'integer', + 'exists:gitlab_projects,id', + Rule::unique('sites', 'gitlab_project_id')->ignore($site->getRouteKey()), + ], + ]); + } +} diff --git a/app/Models/Site.php b/app/Models/Site.php new file mode 100644 index 0000000..429a883 --- /dev/null +++ b/app/Models/Site.php @@ -0,0 +1,124 @@ + */ + use HasFactory; + use HasModelNaming; + use HasTranslations; + use SoftDeletes; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'url_re7', + 'url_prod', + 'project_manager_id', + 'gitlab_project_id', + ]; + + /** + * The translatable columns. + * + * @var list + */ + public $translatable = [ + 'name', + ]; + + /** + * Relations should be included in the cache. + * + * @var list + */ + protected static $cacheRelations = [ + 'projectManager', + 'gitlabProject', + ]; + + /** + * Perform any actions required after the model boots. + * + * @return void + */ + #[\Override] + protected static function booted(): void + { + $assertUrls = function (self $model) { + if ($model->url_re7) { + ToolboxHelper::assertNoInternalUrls('url_re7', $model->url_re7); + } + if ($model->url_prod) { + ToolboxHelper::assertNoInternalUrls('url_prod', $model->url_prod); + } + }; + + static::creating($assertUrls); + static::updating($assertUrls); + } + + // * RELATIONS. + + /** + * Get users associated to the site. + * + * @return \Illuminate\Database\Eloquent\Relations\MorphToMany<\App\Models\User> + */ + public function users(): \Illuminate\Database\Eloquent\Relations\MorphToMany + { + return $this->morphedByMany( + User::class, + 'sitable', + 'sitables', + 'site_id', + 'sitable_id' + )->withPivot('created_at'); + } + + /** + * Get project manager of the site. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\App\Models\User,\App\Models\Site> + */ + public function projectManager(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(User::class, 'project_manager_id'); + } + + /** + * Get project manager of the site. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\App\Models\GitlabProject,\App\Models\Site> + */ + public function gitlabProject(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(GitlabProject::class, 'gitlab_project_id'); + } +} diff --git a/app/Policies/SitePolicy.php b/app/Policies/SitePolicy.php new file mode 100644 index 0000000..449a44f --- /dev/null +++ b/app/Policies/SitePolicy.php @@ -0,0 +1,112 @@ +value); + } + + /** + * Determine whether the user can view trash models. + * + * @param \Illuminate\Database\Eloquent\Model $authModel + * @return boolean + */ + public function viewTrash(Model $authModel): bool + { + return UserStaticRules::atLeastRole($authModel, UserRoleRegistry::get('developper')->value); + } + + /** + * Determine whether the user can view the model. + * + * @param \Illuminate\Database\Eloquent\Model $authModel + * @return boolean + */ + public function view(Model $authModel): bool + { + return UserStaticRules::atLeastRole($authModel, UserRoleRegistry::get('client')->value); + } + + /** + * Determine whether the user can create models. + * + * @param \Illuminate\Database\Eloquent\Model $authModel + * @return boolean + */ + public function create(Model $authModel): bool + { + return UserStaticRules::atLeastRole($authModel, UserRoleRegistry::get('developper')->value); + } + + /** + * Determine whether the user can update the model. + * + * @param \Illuminate\Database\Eloquent\Model $authModel + * @return boolean + */ + public function update(Model $authModel): bool + { + return UserStaticRules::atLeastRole($authModel, UserRoleRegistry::get('developper')->value); + } + + /** + * Determine whether the user can duplicate the model. + * + * @param \Illuminate\Database\Eloquent\Model $authModel + * @return boolean + */ + public function duplicate(Model $authModel): bool + { + return UserStaticRules::atLeastRole($authModel, UserRoleRegistry::get('developper')->value); + } + + /** + * Determine whether the user can restore the model. + * + * @param \Illuminate\Database\Eloquent\Model $authModel + * @return boolean + */ + public function restore(Model $authModel): bool + { + return UserStaticRules::atLeastRole($authModel, UserRoleRegistry::get('developper')->value); + } + + /** + * Determine whether the user can soft delete the model. + * + * @param \Illuminate\Database\Eloquent\Model $authModel + * @return boolean + */ + public function delete(Model $authModel): bool + { + return UserStaticRules::atLeastRole($authModel, UserRoleRegistry::get('developper')->value); + } + + /** + * Determine whether the user can permanently delete the model. + * + * @param \Illuminate\Database\Eloquent\Model $authModel + * @return boolean + */ + public function forceDelete(Model $authModel): bool + { + return UserStaticRules::atLeastRole($authModel, UserRoleRegistry::get('developper')->value); + } +} diff --git a/database/factories/SiteFactory.php b/database/factories/SiteFactory.php new file mode 100644 index 0000000..d827579 --- /dev/null +++ b/database/factories/SiteFactory.php @@ -0,0 +1,49 @@ + + */ + protected $model = Site::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + // Get random project manager or create one. + $enumValue = UserRoleRegistry::get('project-manager')->value; + $projectManager = User::getModelsCached() + ->where('published', true) + ->where('role', $enumValue) + ->shuffle() + ->first() ?? User::factory()->create([ + 'published' => true, + 'role' => $enumValue, + ]); + + return [ + "name" => fake()->unique()->words(3, true), + "url_re7" => fake()->boolean(75) ? fake()->unique()->url() : null, + "url_prod" => fake()->boolean(75) ? fake()->unique()->url() : null, + "project_manager_id" => fake()->boolean(75) ? $projectManager->getKey() : null, + "gitlab_project_id" => fake()->unique()->randomElement(GitlabProject::pluck("id")), + ]; + } +} diff --git a/database/migrations/2026_04_30_160235_create_sites_table.php b/database/migrations/2026_04_30_160235_create_sites_table.php new file mode 100644 index 0000000..c065866 --- /dev/null +++ b/database/migrations/2026_04_30_160235_create_sites_table.php @@ -0,0 +1,40 @@ +id()->comment('Id of the site.'); + $table->string('name')->unique()->comment('Name of the site.'); + $table->string('url_re7')->unique()->nullable()->comment('Re7 url of the site.'); + $table->string('url_prod')->unique()->nullable()->comment('Prod url of the site.'); + $table->foreignId('project_manager_id')->nullable()->comment('Project manager associated.') + ->constrained('users')->onUpdate('restrict')->onDelete('restrict'); + $table->foreignId('gitlab_project_id')->unique()->comment('Gitlab id of the gitlab project.') + ->constrained('gitlab_projects')->onUpdate('restrict')->onDelete('restrict'); + $table->timestamp('created_at')->nullable()->comment('Date the site was created.'); + $table->timestamp('updated_at')->nullable()->comment('Date the site was updated.'); + $table->timestamp('deleted_at')->nullable()->comment('Date the site was deleted.'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + Schema::dropIfExists('sites'); + } +}; diff --git a/database/migrations/2026_05_18_141028_create_sitables_table.php b/database/migrations/2026_05_18_141028_create_sitables_table.php new file mode 100644 index 0000000..5bb7d56 --- /dev/null +++ b/database/migrations/2026_05_18_141028_create_sitables_table.php @@ -0,0 +1,33 @@ +id()->comment('Primary key of the site relation.'); + $table->unsignedBigInteger('site_id')->comment('Primary key of the site.'); + $table->unsignedBigInteger('sitable_id')->comment('Primary key of the associated model.'); + $table->string('sitable_type')->comment('Model associated.'); + $table->timestamp('created_at')->nullable()->comment('Date the site relation was created.'); + + $table->foreign('site_id')->references('id')->on('sites')->onDelete('cascade'); + $table->index(['sitable_id', 'sitable_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sitables'); + } +}; diff --git a/database/seeders/Classic/SiteSeeder.php b/database/seeders/Classic/SiteSeeder.php new file mode 100644 index 0000000..7ea770f --- /dev/null +++ b/database/seeders/Classic/SiteSeeder.php @@ -0,0 +1,25 @@ +create(); + } +} diff --git a/lang/fr/back.php b/lang/fr/back.php index 4e67eb0..bcb5faa 100644 --- a/lang/fr/back.php +++ b/lang/fr/back.php @@ -3,5 +3,11 @@ declare(strict_types=1); return [ - "your_field" => "Votre :field", + "your_field" => "Votre :field", + "model_archived" => ":model est actuellement archivé|:model est actuellement archivée", + "access_url" => "Accéder à l'url", + "form_title_urls" => "URLs d'accès", + "form_subtitle_urls" => "Renseigner les liens de recette et de production liés à ce modèle de données.", + "select_element" => "Sélectionner :element", + "urls" => "URLs", ]; diff --git a/lang/fr/models.php b/lang/fr/models.php index 753b922..fb60184 100644 --- a/lang/fr/models.php +++ b/lang/fr/models.php @@ -7,6 +7,7 @@ return [ "notices" => "information|informations", "invoice-packages" => "devis - forfait|devis - forfaits", "invoice-companies" => "devis - société|devis - sociétés", - "gitlab-projects" => "Gitlab - projet|Gitlab - projets", + "gitlab-projects" => "gitlab - projet|gitlab - projets", + "sites" => "site|sites", ], ]; diff --git a/lang/fr/validation.php b/lang/fr/validation.php index 942aea9..d03aeb8 100644 --- a/lang/fr/validation.php +++ b/lang/fr/validation.php @@ -4,13 +4,18 @@ declare(strict_types=1); return [ "custom" => [ - "token" => "token", - "new_password" => "nouveau mot de passe", - "title" => "titre", - "message" => "message", - "type" => "type", - "name" => "nom", - "amount" => "jour", - "published" => "publié", + "token" => "token", + "new_password" => "nouveau mot de passe", + "title" => "titre", + "message" => "message", + "type" => "type", + "name" => "nom", + "amount" => "jour", + "published" => "publié", + "project_manager_id" => "chef de projet", + "gitlab_id" => "id Gitlab", + "url" => "URL", + "url_re7" => "URL de recette", + "url_prod" => "URL de production", ], ]; \ No newline at end of file diff --git a/resources/ts/back/Components/Layout/Sidebar/Links/Sites.tsx b/resources/ts/back/Components/Layout/Sidebar/Links/Sites.tsx new file mode 100644 index 0000000..998480d --- /dev/null +++ b/resources/ts/back/Components/Layout/Sidebar/Links/Sites.tsx @@ -0,0 +1,73 @@ +// * Packages +import { GlobeIcon } from "lucide-react"; +import { route } from "ziggy-js"; +import { Link } from "@inertiajs/react"; +// * Components shadcn +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +// * Other +import { + filterActions, + NavListLinksProps, +} from "@/back/Components/Layout/Sidebar/AppSidebar"; +import { SidebarGroupLink } from "@/back/Components/Layout/Sidebar/SidebarRegistry"; +import { AuthProp } from "@/back/types/global"; +import str from "@/hooks/use-string"; +import { GetTranslation } from "@/lib/utils"; +import useTrans from "@/hooks/use-translations"; +import Views from "@/back/lib/actions"; + +export default function SidebarLinkSites(auth: AuthProp): SidebarGroupLink { + const modelName = "sites"; + + // Translations. + const transModel = GetTranslation(`models.classes.${modelName}`, { + choice: 2, + }); + const transCreate = str( + useTrans(`uwil-backend::crud.actions.${Views.CREATION.label}`), + ) + .ucFirst() + .value(); + const transArchives = str(useTrans("uwil-backend::back.archives")) + .ucFirst() + .value(); + + const link: NavListLinksProps = { + title: str(transModel).ucFirst().value(), + model: modelName, + icon: GlobeIcon, + show: auth.policies.sites.viewAny, + actions: (model: string) => + filterActions({ + auth, + model, + items: [ + auth.policies.sites.create && ( + + + + ), + auth.policies.sites.viewTrash && ( + + + + ), + ], + }), + }; + + return { link, group: "modules" }; +} diff --git a/resources/ts/back/Pages/Sites/Create.tsx b/resources/ts/back/Pages/Sites/Create.tsx new file mode 100644 index 0000000..b3a60f9 --- /dev/null +++ b/resources/ts/back/Pages/Sites/Create.tsx @@ -0,0 +1,63 @@ +// * Packages +import { usePage } from "@inertiajs/react"; +import { route } from "ziggy-js"; +import { ArrowLeftIcon } from "lucide-react"; +// * Other +import AuthenticatedLayout from "@/back/Layouts/AuthenticatedLayout"; +import Form from "@/back/Pages/Sites/Form"; +import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip"; +import useTrans from "@/hooks/use-translations"; +import { Actions, Methods } from "@/back/lib/enums"; +import { AuthProp, DefaultModel, SingleResource } from "@/back/types/global"; + +export default function Create({ + data, + modelName, +}: { + data: SingleResource; + modelName: string; +}) { + const { viewAny } = (usePage().props.auth as AuthProp).policies.sites; + const model = data.data as DefaultModel; + + // Translations. + const transBackToList = useTrans("uwil-backend::back.crud_back_to_list"); + const transTitle = useTrans( + route().current(`back.${modelName}.duplicate`) + ? "uwil-backend::crud.helpers.duplication_model" + : "uwil-backend::crud.helpers.creation_model", + { + params: { + model: useTrans(`models.classes.${modelName}`, { + choice: 1, + }), + }, + }, + ); + + const beforeNode = () => { + return ( + viewAny && ( +
+ } + tooltipContent={

{transBackToList}

} + /> +
+ ) + ); + }; + + return ( + +
+ + ); +} diff --git a/resources/ts/back/Pages/Sites/Edit.tsx b/resources/ts/back/Pages/Sites/Edit.tsx new file mode 100644 index 0000000..334c1d0 --- /dev/null +++ b/resources/ts/back/Pages/Sites/Edit.tsx @@ -0,0 +1,71 @@ +// * Packages +import { usePage } from "@inertiajs/react"; +import { route } from "ziggy-js"; +import { ArrowLeftIcon } from "lucide-react"; +// * Other +import AuthenticatedLayout from "@/back/Layouts/AuthenticatedLayout"; +import ModelActions from "@/back/Components/ModelActions"; +import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip"; +import Form from "@/back/Pages/Sites/Form"; +import useTrans from "@/hooks/use-translations"; +import { Actions, Methods } from "@/back/lib/enums"; +import { Views } from "@/back/lib/actions"; +import { AuthProp, DefaultModel, SingleResource } from "@/back/types/global"; + +export default function Edit({ + data, + modelName, +}: { + data: SingleResource; + modelName: string; +}) { + const { viewAny } = (usePage().props.auth as AuthProp).policies.sites; + const model = data.data as DefaultModel; + const actionModels = [ + Views.VISUALIZATION, + Views.DUPLICATION, + Views.ARCHIVATION, + ]; + + // Translations. + const transBackToList = useTrans("uwil-backend::back.crud_back_to_list"); + const transTitle = useTrans("uwil-backend::crud.helpers.edition_model", { + params: { + model: useTrans(`models.classes.${modelName}`, { + choice: 1, + }), + }, + }); + + const beforeNode = () => { + return ( + viewAny && ( +
+ } + tooltipContent={

{transBackToList}

} + /> +
+ ) + ); + }; + + return ( + + + + + ); +} diff --git a/resources/ts/back/Pages/Sites/Form.tsx b/resources/ts/back/Pages/Sites/Form.tsx new file mode 100644 index 0000000..7f9a0e5 --- /dev/null +++ b/resources/ts/back/Pages/Sites/Form.tsx @@ -0,0 +1,313 @@ +// * Packages +import { useForm, usePage } from "@inertiajs/react"; +import { BookTextIcon, ExternalLinkIcon } from "lucide-react"; +// * Other +import FieldsetWrapper from "@/back/Components/FieldsetWrapper"; +import useTrans from "@/hooks/use-translations"; +import InputError from "@/back/Components/InputError"; +import SubmitButton from "@/back/Components/SubmitButton"; +import useCrudSubmit from "@/back/hooks/use-crud-submit"; +import SimpleString from "@/back/Components/Inputs/SimpleString"; +import { + MultiSelect, + MultiSelectOption, +} from "@/back/Components/Inputs/MultiSelect"; +import { UserModel } from "@/back/types/global"; +import str from "@/hooks/use-string"; +import { GitlabProjectModel, SiteModel } from "@/back/types/project"; +import { AppEnv } from "@/types/global"; +import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip"; +import { GetTranslation, isAValidUrl } from "@/lib/utils"; + +export default function Form({ + model, + modelName, + modelRoute, + action, + method, +}: { + model?: SiteModel; + modelName: string; + modelRoute: string; + action: string; + method: number; +}) { + const { + appEnv, + userModelsProjectManager, + userModelsClient, + gitlabProjectModels, + } = usePage().props as unknown as { + appEnv: AppEnv; + userModelsProjectManager: Array; + userModelsClient: Array; + gitlabProjectModels: Array; + }; + + /** Form section */ + const { post, setData, data } = useForm({ + name: model?.name ? model?.name[appEnv.locale] : undefined, + url_re7: model?.url_re7 ?? undefined, + url_prod: model?.url_prod ?? undefined, + project_manager_id: model?.project_manager_id ?? undefined, + gitlab_project_id: model?.gitlab_project_id ?? undefined, + users: + model && model.users + ? model?.users?.map((user) => String(user.id)) + : undefined, + }); + + const submit = useCrudSubmit({ + method, + modelRoute, + modelName, + action, + model, + post, + data, + modelNameTrans: useTrans(`models.classes.${modelName}`, { + choice: 1, + }), + }); + + /** + * Format collection before append to multi select input. + * @param {Array} collection + * @return {Array} + */ + function formatSelectOptions( + collection: Array, + // eslint-disable-next-line no-unused-vars + getLabel: (targetModel: T) => string, + ) { + return collection.map((userModel: T) => { + const updatedModel = { + label: "", + value: "", + } as MultiSelectOption; + updatedModel.label = getLabel(userModel); + updatedModel.value = String(userModel.id); + return updatedModel; + }); + } + + const selectOptionsProjectManager = formatSelectOptions( + userModelsProjectManager, + (userModel) => `${userModel.first_name} ${userModel.last_name}`, + ); + const selectOptionsClient = formatSelectOptions( + userModelsClient, + (userModel) => `${userModel.first_name} ${userModel.last_name}`, + ); + const selectOptionsProject = formatSelectOptions( + gitlabProjectModels, + (gitlabProjectModel) => gitlabProjectModel.name[appEnv.locale], + ); + + return ( + + + } + description={useTrans("uwil-backend::back.form_subtitle_global")} + className="sm:grid-cols-2 gap-4" + > + + + + + + } + description={useTrans("back.form_subtitle_urls")} + className="sm:grid-cols-2 gap-4" + > +
+ + {isAValidUrl(data.url_re7) && ( + } + tooltipContent={GetTranslation("back.access_url")} + link={data.url_re7} + externalLink + type="button" + size="icon" + /> + )} +
+
+ + {isAValidUrl(data.url_prod) && ( + } + tooltipContent={GetTranslation("back.access_url")} + link={data.url_prod} + externalLink + type="button" + size="icon" + /> + )} +
+
+
+ +
+ + ); +} diff --git a/resources/ts/back/Pages/Sites/Index.tsx b/resources/ts/back/Pages/Sites/Index.tsx new file mode 100644 index 0000000..565546b --- /dev/null +++ b/resources/ts/back/Pages/Sites/Index.tsx @@ -0,0 +1,316 @@ +// * Packages +import { route } from "ziggy-js"; +import { usePage } from "@inertiajs/react"; +import { Row } from "@tanstack/react-table"; +import { + ArchiveIcon, + ArrowLeftIcon, + PlusIcon, + TriangleAlertIcon, + UserIcon, +} from "lucide-react"; +// * Components shadcn +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +// * Other +import AuthenticatedLayout from "@/back/Layouts/AuthenticatedLayout"; +import DataTable from "@/back/Components/Table/DataTable"; +import DataTableSearch from "@/back/Components/Table/DataTableSearch"; +import DataTableFilterButton from "@/back/Components/Table/DataTableFilterButton"; +import SoftdeleteWarning from "@/back/Components/SoftdeleteWarning"; +import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip"; +import { GetFormatedDate, GetTranslation } from "@/lib/utils"; +import { Views } from "@/back/lib/actions"; +import { AuthProp, Resource } from "@/back/types/global"; +import str from "@/hooks/use-string"; +import useTrans from "@/hooks/use-translations"; +import { SiteModel } from "@/back/types/project"; +import RenderTranslationCell from "@/back/Components/Table/RenderTranslationCell"; + +const getColumns = (viewAnyUsers: boolean) => { + return [ + { + accessorKey: "name", + header: ({ column }: { column: { id: string } }) => ( + + ), + cell: ({ row }: { row: Row }) => ( + + ), + }, + { + accessorKey: "url_re7", + header: ({ column }: { column: { id: string } }) => ( +

+ {str(GetTranslation(`validation.custom.${column.id}`)) + .ucFirst() + .value()} +

+ ), + cell: ({ row }: { row: Row }) => + row.original.url_re7 ? ( + + + + {row.original.url_re7} + + + {GetTranslation("back.access_url")} + + ) : ( +

+ {GetTranslation("uwil-backend::back.table_not_known")} +

+ ), + }, + { + accessorKey: "url_prod", + header: ({ column }: { column: { id: string } }) => ( +

+ {str(GetTranslation(`validation.custom.${column.id}`)) + .ucFirst() + .value()} +

+ ), + cell: ({ row }: { row: Row }) => + row.original.url_prod ? ( + + + + {row.original.url_prod} + + + {GetTranslation("back.access_url")} + + ) : ( +

+ {GetTranslation("uwil-backend::back.table_not_known")} +

+ ), + }, + { + accessorKey: "project_manager_id", + header: ({ column }: { column: { id: string } }) => ( + + ), + cell: ({ row }: { row: Row }) => { + if (row.original.project_manager) { + const userModel = row.original.project_manager; + const content = ( + + {userModel.first_name} {userModel.last_name} + + ); + let value = ( +
+ {content} + {userModel.deleted_at !== null && ( + + + + + + {GetTranslation("back.model_archived", { + params: { + model: str( + GetTranslation("uwil-backend::models.classes.users", { + choice: 1, + }), + ) + .ucFirst() + .value(), + }, + choice: 1, + })} + + + )} +
+ ); + if (viewAnyUsers && userModel.deleted_at === null) { + value = ( + + + {content} + + } + tooltipContent={ +

+ {GetTranslation("uwil-backend::back.access_model", { + params: { + model: GetTranslation( + "uwil-backend::models.classes.users", + { + choice: 1, + }, + ), + }, + })} +

+ } + type="button" + size="default" + variant="secondary" + /> + ); + } + return value; + } + return ( +

+ {GetTranslation("uwil-backend::back.table_not_known")} +

+ ); + }, + }, + { + accessorKey: "updated_at", + header: ({ column }: { column: { id: string } }) => ( + + ), + cell: ({ row }: { row: Row }) => ( + + {GetFormatedDate(row.getValue("updated_at"))} + + ), + }, + ]; +}; + +export default function Index({ + models, + searchFields, + modelName, +}: { + models: Resource; + searchFields: string; + modelName: string; +}) { + const { auth } = usePage().props as unknown as { + auth: AuthProp; + }; + const { viewAny, viewTrash, create } = auth.policies.sites; + const { viewAny: viewAnyUsers } = auth.policies.users; + const isArchiveRoute = route().current(`back.${modelName}.trash`); + const actionModels = isArchiveRoute + ? [Views.RESTORATION, Views.FORCEDELETION] + : [ + Views.VISUALIZATION, + Views.EDITION, + Views.DUPLICATION, + Views.ARCHIVATION, + ]; + const columns = getColumns(viewAnyUsers); + + // Translations. + const transModel = useTrans(`models.classes.${modelName}`, { choice: 2 }); + const transArchiveTitle = useTrans( + "uwil-backend::crud.helpers.archives_model", + { + params: { model: transModel }, + }, + ); + const transTitle = str(isArchiveRoute ? transArchiveTitle : transModel) + .ucFirst() + .value(); + const transCrudBackList = useTrans("uwil-backend::back.crud_back_to_list"); + + const beforeNode = () => { + return ( + isArchiveRoute && + viewAny && ( +
+ } + tooltipContent={

{transCrudBackList}

} + /> +
+ ) + ); + }; + const transCrudAddEntry = useTrans("uwil-backend::back.crud_add_entry"); + const transAccessTo = useTrans("uwil-backend::back.crud_access_to", { + params: { + target: useTrans("uwil-backend::back.archived_entries"), + }, + }); + const afterNode = () => { + return ( + !isArchiveRoute && ( +
+ {viewTrash && ( + } + tooltipContent={

{transAccessTo}

} + variant="outline" + /> + )} + {create && ( + } + tooltipContent={

{transCrudAddEntry}

} + /> + )} +
+ ) + ); + }; + + return ( + + {isArchiveRoute && } + + + + ); +} diff --git a/resources/ts/back/Pages/Sites/Show.tsx b/resources/ts/back/Pages/Sites/Show.tsx new file mode 100644 index 0000000..31603c6 --- /dev/null +++ b/resources/ts/back/Pages/Sites/Show.tsx @@ -0,0 +1,341 @@ +// * Packages +import { usePage } from "@inertiajs/react"; +import { route } from "ziggy-js"; +import { + ArrowLeftIcon, + FolderGitIcon, + TriangleAlertIcon, + UserIcon, +} from "lucide-react"; +// * Components shadcn +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +// * Other +import AuthenticatedLayout from "@/back/Layouts/AuthenticatedLayout"; +import DetailsTable from "@/back/Components/Table/DetailsTable"; +import ModelActions from "@/back/Components/ModelActions"; +import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip"; +import RenderTranslationCell from "@/back/Components/Table/RenderTranslationCell"; +import { GetFormatedDate, GetTranslation } from "@/lib/utils"; +import { Views } from "@/back/lib/actions"; +import { AuthProp, SingleResource, UserModel } from "@/back/types/global"; +import str from "@/hooks/use-string"; +import useTrans from "@/hooks/use-translations"; +import { AppEnv } from "@/types/global"; +import { TranslationField } from "@/back/types/i18n"; +import { GitlabProjectModel, SiteModel } from "@/back/types/project"; + +const getColumns = (viewAnyUsers: boolean, appEnv: AppEnv) => { + return [ + { + accessorKey: "name", + header: str(GetTranslation("validation.custom.name")).ucFirst().value(), + cell: ({ value }: { value: TranslationField }) => ( + + ), + }, + { + accessorKey: "url_re7", + header: str(GetTranslation("validation.custom.url_re7")) + .ucFirst() + .value(), + cell: ({ value }: { value: string | null }) => + value ? ( + + + + {value} + + + {GetTranslation("back.access_url")} + + ) : ( +

{GetTranslation("uwil-backend::trans.table_not_known")}

+ ), + }, + { + accessorKey: "url_prod", + header: str(GetTranslation("validation.custom.url_prod")) + .ucFirst() + .value(), + cell: ({ value }: { value: string | null }) => + value ? ( + + + + {value} + + + {GetTranslation("back.access_url")} + + ) : ( +

{GetTranslation("uwil-backend::trans.table_not_known")}

+ ), + }, + { + accessorKey: "project_manager", + header: str(GetTranslation("validation.custom.project_manager_id")) + .ucFirst() + .value(), + cell: ({ value }: { value: UserModel | null }) => { + if (value) { + const userData = ( + + {value.first_name} {value.last_name} + + ); + let content = ( +
+ {userData} + {value.deleted_at !== null && ( + + + + + + {GetTranslation("back.model_archived", { + params: { + model: str( + GetTranslation("uwil-backend::models.classes.users", { + choice: 1, + }), + ) + .ucFirst() + .value(), + }, + choice: 1, + })} + + + )} +
+ ); + if (viewAnyUsers && value.deleted_at === null) { + content = ( + + + {userData} + + } + tooltipContent={ +

+ {GetTranslation("uwil-backend::back.access_model", { + params: { + model: GetTranslation( + "uwil-backend::models.classes.users", + { + choice: 1, + }, + ), + }, + })} +

+ } + type="button" + size="default" + variant="secondary" + /> + ); + } + return content; + } + return ( +

+ {GetTranslation("uwil-backend::back.table_not_known")} +

+ ); + }, + }, + { + accessorKey: "gitlab_project", + header: str( + GetTranslation("models.classes.gitlab-projects", { + choice: 1, + }), + ) + .ucFirst() + .value(), + cell: ({ value }: { value: GitlabProjectModel | null }) => { + if (value) { + const projectData = {value.name[appEnv.locale]}; + let content = ( +
+ {projectData} + {value.deleted_at !== null && ( + + + + + + {GetTranslation("back.model_archived", { + params: { + model: str( + GetTranslation("models.classes.gitlab-projects", { + choice: 1, + }), + ) + .ucFirst() + .value(), + }, + choice: 1, + })} + + + )} +
+ ); + if (viewAnyUsers && value.deleted_at === null) { + content = ( + + + {projectData} + + } + tooltipContent={ +

+ {GetTranslation("uwil-backend::back.access_model", { + params: { + model: GetTranslation( + "models.classes.gitlab-projects", + { + choice: 1, + }, + ), + }, + })} +

+ } + type="button" + size="default" + variant="secondary" + /> + ); + } + return content; + } + return ( +

+ {GetTranslation("uwil-backend::back.table_not_known")} +

+ ); + }, + }, + { + accessorKey: "created_at", + header: str( + GetTranslation("uwil-backend::validation.attributes.created_at"), + ) + .ucFirst() + .value(), + cell: ({ value }: { value: string }) => ( + {GetFormatedDate(value)} + ), + }, + { + accessorKey: "updated_at", + header: str( + GetTranslation("uwil-backend::validation.attributes.updated_at"), + ) + .ucFirst() + .value(), + cell: ({ value }: { value: string }) => ( + {GetFormatedDate(value)} + ), + }, + { + accessorKey: "deleted_at", + header: str( + GetTranslation("uwil-backend::validation.attributes.deleted_at"), + ) + .ucFirst() + .value(), + cell: ({ value }: { value: string }) => ( + {GetFormatedDate(value)} + ), + }, + ]; +}; + +export default function Show({ + data, + modelName, +}: { + data: SingleResource; + modelName: string; +}) { + const { auth, appEnv } = usePage().props as unknown as { + auth: AuthProp; + appEnv: AppEnv; + }; + const { viewAny } = auth.policies.sites; + const { viewAny: viewAnyUsers } = auth.policies.users; + const model = data.data as SiteModel; + const actionModels = [Views.EDITION, Views.DUPLICATION, Views.ARCHIVATION]; + const transTitle = useTrans( + "uwil-backend::crud.helpers.visualization_model", + { + params: { + model: useTrans(`models.classes.${modelName}`, { + choice: 1, + }), + }, + }, + ); + const columns = getColumns(viewAnyUsers, appEnv); + + // Translations. + const transCrudBackToList = useTrans("uwil-backend::back.crud_back_to_list"); + + const beforeNode = () => { + return ( + viewAny && ( +
+ } + tooltipContent={

{transCrudBackToList}

} + /> +
+ ) + ); + }; + + return ( + + + {/* @ts-expect-error Weird error type on cell. */} + + + ); +} diff --git a/routes/back/sites.php b/routes/back/sites.php new file mode 100644 index 0000000..deab78b --- /dev/null +++ b/routes/back/sites.php @@ -0,0 +1,14 @@ +name('sites.trash'); +Route::patch('/sites/{site}/restore', [SiteController::class, 'restore']) + ->name('sites.restore'); +Route::delete('/sites/{site}/force', [SiteController::class, 'forceDestroy']) + ->name('sites.forceDestroy'); +Route::resource('sites', SiteController::class); +Route::get('/sites/{site}/duplicate', [SiteController::class, 'duplicate']) + ->name('sites.duplicate'); diff --git a/routes/breadcrumbs/sites.php b/routes/breadcrumbs/sites.php new file mode 100644 index 0000000..943ed45 --- /dev/null +++ b/routes/breadcrumbs/sites.php @@ -0,0 +1,45 @@ +push( + Str::ucfirst(trans_choice('models.classes.sites', \INF)), + route('back.sites.index') + ); +}); +Breadcrumbs::for('back.sites.create', function (Generator $trail) { + $trail->parent('back.sites.index'); + $trail->push( + Str::ucfirst(trans('uwil-backend::crud.actions.create')), + route('back.sites.create'), + ); +}); +Breadcrumbs::for('back.sites.edit', function (Generator $trail) { + $trail->parent('back.sites.index'); + $trail->push( + Str::ucfirst(trans('uwil-backend::crud.actions.edit')), + route('back.sites.edit', [ + 'site' => request()->site, + ]), + ); +}); +Breadcrumbs::for('back.sites.duplicate', function (Generator $trail) { + $trail->parent('back.sites.index'); + $trail->push(Str::ucfirst(trans('uwil-backend::crud.actions.duplicate'))); +}); +Breadcrumbs::for('back.sites.show', function (Generator $trail) { + $trail->parent('back.sites.index'); + $trail->push( + Str::ucfirst(trans('uwil-backend::crud.actions.visualize')), + route('back.sites.show', [ + 'site' => request()->site, + ]), + ); +}); +Breadcrumbs::for('back.sites.trash', function (Generator $trail) { + $trail->parent('back.sites.index'); + $trail->push(Str::ucfirst(trans('uwil-backend::back.archives'))); +});