Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions assets/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,10 @@
background-image: linear-gradient(rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.6)), url('/images/overlay.png');
background-size: cover;
}

/* Modal scroll prevention with content jump fix */
body.modal-open {
overflow: hidden;
padding-right: var(--scrollbar-width, 0px);
}
}
3 changes: 2 additions & 1 deletion assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ import { LiveSocket } from "phoenix_live_view";
import { hooks } from "phoenix-colocated/cen";
import topbar from "../vendor/topbar";
import Croppr from "./croppr";
import { ModalScrollFix } from "./modal_scroll_fix";

const csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute("content");
const liveSocket = new LiveSocket("/live", Socket, {
hooks: { Croppr, ...hooks },
hooks: { Croppr, ModalScrollFix, ...hooks },
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken },
});
Expand Down
21 changes: 21 additions & 0 deletions assets/js/modal_scroll_fix.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Modal scroll fix to prevent content jumping
export const ModalScrollFix = {
mounted() {
this.updateScrollbarWidth();
this.resizeHandler = () => this.updateScrollbarWidth();
window.addEventListener('resize', this.resizeHandler);
},

destroyed() {
if (this.resizeHandler) {
window.removeEventListener('resize', this.resizeHandler);
}
},

updateScrollbarWidth() {
// Calculate scrollbar width
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
// Set CSS custom property
document.documentElement.style.setProperty('--scrollbar-width', `${scrollbarWidth}px`);
}
};
22 changes: 16 additions & 6 deletions lib/cen/publications.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@ defmodule Cen.Publications do
@spec get_vacancy!(id :: integer() | binary()) :: Vacancy.t()
def get_vacancy!(id) do
Vacancy
|> Vacancy.not_deleted()
|> Repo.get!(id)
|> Repo.preload(:organization)
end

@spec list_vacancies_for(User.t()) :: [Vacancy.t()]
def list_vacancies_for(user) do
user
|> Repo.preload(vacancies: :organization)
|> Repo.preload(vacancies: {Vacancy.not_deleted(), :organization})
|> Map.get(:vacancies)
end

Expand Down Expand Up @@ -58,22 +59,25 @@ defmodule Cen.Publications do
)
end

@spec delete_vacancy(Vacancy.t()) :: :ok
@spec delete_vacancy(Vacancy.t()) :: {:ok, Vacancy.t()} | {:error, Ecto.Changeset.t()}
def delete_vacancy(vacancy) do
Repo.delete(vacancy)
vacancy
|> Vacancy.soft_delete_changeset()
|> Repo.update()
end

@spec get_resume!(id :: integer() | binary()) :: Resume.t()
def get_resume!(id) do
Resume
|> Resume.not_deleted()
|> Repo.get!(id)
|> Repo.preload([:user])
end

@spec list_resumes_for(user :: User.t()) :: [Resume.t()]
def list_resumes_for(user) do
user
|> Repo.preload(resumes: :user)
|> Repo.preload(resumes: {Resume.not_deleted(), :user})
|> Map.get(:resumes)
end

Expand All @@ -97,9 +101,11 @@ defmodule Cen.Publications do
|> Repo.update()
end

@spec delete_resume(Resume.t()) :: :ok
@spec delete_resume(Resume.t()) :: {:ok, Resume.t()} | {:error, Ecto.Changeset.t()}
def delete_resume(resume) do
Repo.delete(resume)
resume
|> Resume.soft_delete_changeset()
|> Repo.update()
end

@spec search_resumes(map()) :: {:ok, {[Resume.t()], Flop.Meta.t()}} | {:error, Flop.Meta.t()}
Expand All @@ -109,6 +115,7 @@ defmodule Cen.Publications do
|> Ecto.Changeset.apply_action(:validate) do
{:ok, filters} ->
Resume
|> Resume.not_deleted()
|> filter(:searchable, :search, filters.query)
|> filter(:field_of_art, :eq, filters.field_of_art)
|> Filters.filter_employment_types(filters.employment_types)
Expand All @@ -131,6 +138,7 @@ defmodule Cen.Publications do
|> Ecto.Changeset.apply_action(:validate) do
{:ok, filters} ->
Vacancy
|> Vacancy.not_deleted()
|> filter(:searchable, :search, filters.query)
|> filter(:field_of_art, :eq, filters.field_of_art)
|> filter(:min_years_of_work_experience, :not_gt, filters.min_years_of_work_experience)
Expand Down Expand Up @@ -164,6 +172,7 @@ defmodule Cen.Publications do
@spec list_not_reviewed_resumes() :: [Resume.t()]
def list_not_reviewed_resumes do
Resume
|> Resume.not_deleted()
|> where([resume], is_nil(resume.reviewed_at))
|> order_by([resume], asc: resume.updated_at)
|> preload(:user)
Expand All @@ -187,6 +196,7 @@ defmodule Cen.Publications do
@spec list_not_reviewed_vacancies() :: [Vacancy.t()]
def list_not_reviewed_vacancies do
Vacancy
|> Vacancy.not_deleted()
|> where([vacancy], is_nil(vacancy.reviewed_at))
|> order_by([vacancy], asc: vacancy.updated_at)
|> preload(:organization)
Expand Down
25 changes: 25 additions & 0 deletions lib/cen/publications/resume.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ defmodule Cen.Publications.Resume do
use Ecto.Schema

import Ecto.Changeset
import Ecto.Query

alias Cen.Accounts.User
alias Cen.Publications.Enums
Expand All @@ -20,6 +21,7 @@ defmodule Cen.Publications.Resume do
field :work_schedules, {:array, Ecto.Enum}, values: Enums.work_schedules()

field :reviewed_at, :utc_datetime
field :deleted_at, :utc_datetime

embeds_many :educations, Education, on_replace: :delete
embeds_many :jobs, Job, on_replace: :delete
Expand Down Expand Up @@ -90,4 +92,27 @@ defmodule Cen.Publications.Resume do
drop_param: :jobs_drop
)
end

@doc """
Soft deletes the resume by setting `deleted_at`.
"""
@spec soft_delete_changeset(t() | Ecto.Changeset.t()) :: Ecto.Changeset.t()
def soft_delete_changeset(resume) do
now = DateTime.utc_now(:second)
change(resume, deleted_at: now)
end

@doc """
Query scope to exclude soft-deleted resumes.
"""
def not_deleted(query \\ __MODULE__) do
from(r in query, where: is_nil(r.deleted_at))
end

@doc """
Query scope to include only soft-deleted resumes.
"""
def deleted_only(query \\ __MODULE__) do
from(r in query, where: not is_nil(r.deleted_at))
end
end
25 changes: 25 additions & 0 deletions lib/cen/publications/vacancy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ defmodule Cen.Publications.Vacancy do
use Ecto.Schema

import Ecto.Changeset
import Ecto.Query

alias Cen.Accounts.User
alias Cen.Employers.Organization
Expand All @@ -24,6 +25,7 @@ defmodule Cen.Publications.Vacancy do
field :proposed_salary, :integer

field :reviewed_at, :utc_datetime
field :deleted_at, :utc_datetime

belongs_to :user, User
belongs_to :organization, Organization
Expand Down Expand Up @@ -101,4 +103,27 @@ defmodule Cen.Publications.Vacancy do
defp validate_proposed_salary(changeset) do
changeset
end

@doc """
Soft deletes the vacancy by setting `deleted_at`.
"""
@spec soft_delete_changeset(t() | Ecto.Changeset.t()) :: Ecto.Changeset.t()
def soft_delete_changeset(vacancy) do
now = DateTime.utc_now(:second)
change(vacancy, deleted_at: now)
end

@doc """
Query scope to exclude soft-deleted vacancies.
"""
def not_deleted(query \\ __MODULE__) do
from(v in query, where: is_nil(v.deleted_at))
end

@doc """
Query scope to include only soft-deleted vacancies.
"""
def deleted_only(query \\ __MODULE__) do
from(v in query, where: not is_nil(v.deleted_at))
end
end
4 changes: 2 additions & 2 deletions lib/cen_web/components/core_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,7 @@ defmodule CenWeb.CoreComponents do
transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
)
|> show("##{id}-container")
|> JS.add_class("overflow-hidden", to: "body")
|> JS.add_class("modal-open", to: "body")
|> JS.focus_first(to: "##{id}-content")
end

Expand All @@ -902,7 +902,7 @@ defmodule CenWeb.CoreComponents do
)
|> hide("##{id}-container")
|> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
|> JS.remove_class("overflow-hidden", to: "body")
|> JS.remove_class("modal-open", to: "body")
|> JS.pop_focus()
end

Expand Down
62 changes: 62 additions & 0 deletions lib/cen_web/components/delete_confirmation_component.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
defmodule CenWeb.DeleteConfirmationComponent do
@moduledoc """
Universal delete confirmation modal component.

## Usage

<.delete_confirmation
show={@show_delete_modal}
title="Подтвердите удаление резюме"
message="Это действие нельзя отменить. Резюме будет безвозвратно удалено."
confirm_event="delete_resume"
cancel_event="cancel_delete"
/>
"""
use CenWeb, :html
use Phoenix.Component

@doc """
Delete confirmation modal component.

## Attributes

* `show` (required) - Boolean to control modal visibility
* `title` (required) - Modal title text
* `message` (required) - Confirmation message text
* `confirm_event` (required) - Event name for confirm action
* `cancel_event` (required) - Event name for cancel action
* `confirm_text` - Confirm button text (defaults to "Да, удалить")
* `cancel_text` - Cancel button text (defaults to "Отмена")
"""
attr :show, :boolean, required: true
attr :title, :string, required: true
attr :message, :string, required: true
attr :confirm_event, :string, required: true
attr :cancel_event, :string, required: true
attr :confirm_text, :string, default: "Да, удалить"
attr :cancel_text, :string, default: "Отмена"

def delete_confirmation(assigns) do
~H"""
<.modal :if={@show} show id="delete_confirmation_modal" on_cancel={JS.push(@cancel_event)}>
<div class="text-center">
<.icon name="cen-trash-xmark" class="mx-auto mb-4 h-12 w-12 text-red-500" />
<h3 class="mb-4 text-lg font-medium text-gray-900">
{@title}
</h3>
<p class="mb-6 text-sm text-gray-500">
{@message}
</p>
<div class="flex justify-center gap-3">
<.button class="rounded bg-gray-300 px-4 py-2 text-gray-800 hover:bg-gray-400" phx-click={@cancel_event}>
{@cancel_text}
</.button>
<.button class="rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700" phx-click={@confirm_event}>
{@confirm_text}
</.button>
</div>
</div>
</.modal>
"""
end
end
2 changes: 1 addition & 1 deletion lib/cen_web/components/layouts/root.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<script defer phx-track-static src={~p"/assets/app.js"}>
</script>
</head>
<body class="bg-background flex h-screen flex-col justify-between">
<body id="body" class="bg-background flex h-screen flex-col justify-between" phx-hook="ModalScrollFix">
<div class="shadow-navbar relative z-10">
<ul class="container mx-auto hidden h-20 items-center justify-start gap-10 lg:flex">
<li>
Expand Down
38 changes: 34 additions & 4 deletions lib/cen_web/live/resume_live/show.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ defmodule CenWeb.ResumeLive.Show do
alias Cen.Publications
alias Cen.Utils.CalendarTranslations
alias Cen.Utils.GettextEnums
alias CenWeb.DeleteConfirmationComponent

@impl Phoenix.LiveView
def render(assigns) do
Expand Down Expand Up @@ -40,7 +41,7 @@ defmodule CenWeb.ResumeLive.Show do
<.regular_button class="bg-accent-hover" phx-click={JS.navigate(~p"/cvs/#{@resume}/edit")}>
{gettext("Редактировать")}
</.regular_button>
<.button class="bg-white p-4" phx-click="delete_resume">
<.button class="bg-white p-4" phx-click="confirm_delete_resume">
<.icon name="cen-bin" alt={dgettext("publications", "Удалить")} />
</.button>
<% end %>
Expand Down Expand Up @@ -132,6 +133,16 @@ defmodule CenWeb.ResumeLive.Show do
</.simple_form>
</.header>
</.modal>

<DeleteConfirmationComponent.delete_confirmation
show={@show_delete_modal}
title="Подтвердите удаление резюме"
message="Это действие нельзя отменить. Резюме будет безвозвратно удалено."
confirm_event="delete_resume"
cancel_event="cancel_delete_resume"
confirm_text="Да, удалить"
cancel_text="Отмена"
/>
"""
end

Expand All @@ -157,7 +168,7 @@ defmodule CenWeb.ResumeLive.Show do
resume = Publications.get_resume!(id)
verify_has_permission!(socket.assigns.current_user, resume, :show)

{:ok, assign(socket, resume: resume, select_vacancy_form: to_form(%{}, as: "select_vacancy_params"), vacancies: [])}
{:ok, assign(socket, resume: resume, select_vacancy_form: to_form(%{}, as: "select_vacancy_params"), vacancies: [], show_delete_modal: false)}
end

@impl Phoenix.LiveView
Expand All @@ -178,9 +189,28 @@ defmodule CenWeb.ResumeLive.Show do
end

@impl Phoenix.LiveView
def handle_event("confirm_delete_resume", _params, socket) do
{:noreply, assign(socket, show_delete_modal: true)}
end

def handle_event("cancel_delete_resume", _params, socket) do
{:noreply, assign(socket, show_delete_modal: false)}
end

def handle_event("delete_resume", _params, socket) do
Publications.delete_resume(socket.assigns.resume)
{:noreply, push_navigate(socket, to: ~p"/cvs")}
case Publications.delete_resume(socket.assigns.resume) do
{:ok, _resume} ->
{:noreply,
socket
|> put_flash(:info, dgettext("publications", "Резюме успешно удалено."))
|> push_navigate(to: ~p"/cvs")}

{:error, _changeset} ->
{:noreply,
socket
|> put_flash(:error, dgettext("publications", "Произошла ошибка при удалении резюме."))
|> assign(show_delete_modal: false)}
end
end

def handle_event("choose_vacancy", %{"select_vacancy_params" => select_vacancy_params}, socket) do
Expand Down
Loading