diff --git a/assets/css/components/button.css b/assets/css/components/button.css index 52123f2ba..afe6bc3ab 100644 --- a/assets/css/components/button.css +++ b/assets/css/components/button.css @@ -1,7 +1,7 @@ /* Buttons */ .atomic-button { - @apply inline-flex items-center justify-center font-medium transition duration-150 ease-in-out border rounded-md focus:outline-none; + @apply inline-flex items-center justify-center font-medium transition duration-150 ease-in-out border rounded-md select-none focus:outline-none; } /* Buttons - sizes */ diff --git a/lib/atomic/accounts/user.ex b/lib/atomic/accounts/user.ex index ca67ec77a..248dd3775 100644 --- a/lib/atomic/accounts/user.ex +++ b/lib/atomic/accounts/user.ex @@ -6,7 +6,7 @@ defmodule Atomic.Accounts.User do alias Atomic.Accounts.Course alias Atomic.Activities.ActivityEnrollment - alias Atomic.Organizations.{Membership, Organization} + alias Atomic.Organizations.{Collaborator, Membership, Organization} alias Atomic.Uploaders.ProfilePicture @required_fields ~w(email password)a @@ -23,14 +23,17 @@ defmodule Atomic.Accounts.User do field :password, :string, virtual: true, redact: true field :hashed_password, :string, redact: true field :confirmed_at, :naive_datetime - field :phone_number, :string field :profile_picture, ProfilePicture.Type field :role, Ecto.Enum, values: @roles, default: :student + belongs_to :course, Course belongs_to :current_organization, Organization has_many :activity_enrollments, ActivityEnrollment + has_many :memberships, Membership + has_many :collaborators, Collaborator + many_to_many :organizations, Organization, join_through: Membership timestamps() diff --git a/lib/atomic/departments.ex b/lib/atomic/departments.ex index facb25a1a..1abf710d0 100644 --- a/lib/atomic/departments.ex +++ b/lib/atomic/departments.ex @@ -4,7 +4,10 @@ defmodule Atomic.Departments do """ use Atomic.Context + alias Atomic.Accounts.User alias Atomic.Organizations.{Collaborator, Department} + alias AtomicWeb.DepartmentEmails + alias AtomicWeb.Router.Helpers @doc """ Returns the list of departments. @@ -87,10 +90,11 @@ defmodule Atomic.Departments do {:error, %Ecto.Changeset{}} """ - def create_department(attrs \\ %{}) do + def create_department(attrs \\ %{}, after_save \\ &{:ok, &1}) do %Department{} |> Department.changeset(attrs) |> Repo.insert() + |> after_save(after_save) end @doc """ @@ -105,10 +109,11 @@ defmodule Atomic.Departments do {:error, %Ecto.Changeset{}} """ - def update_department(%Department{} = department, attrs) do + def update_department(%Department{} = department, attrs, after_save \\ &{:ok, &1}) do department |> Department.changeset(attrs) |> Repo.update() + |> after_save(after_save) end @doc """ @@ -127,6 +132,42 @@ defmodule Atomic.Departments do Repo.delete(department) end + @doc """ + Archives a department. + + ## Examples + + iex> archive_department(department) + {:ok, %Department{}} + + iex> archive_department(department) + {:error, %Ecto.Changeset{}} + + """ + def archive_department(%Department{} = department) do + department + |> Department.changeset(%{archived: true}) + |> Repo.update() + end + + @doc """ + Unarchives a department. + + ## Examples + + iex> unarchive_department(department) + {:ok, %Department{}} + + iex> unarchive_department(department) + {:error, %Ecto.Changeset{}} + + """ + def unarchive_department(%Department{} = department) do + department + |> Department.changeset(%{archived: false}) + |> Repo.update() + end + @doc """ Returns an `%Ecto.Changeset{}` for tracking department changes. @@ -180,7 +221,11 @@ defmodule Atomic.Departments do ** (Ecto.NoResultsError) """ - def get_collaborator!(id), do: Repo.get!(Collaborator, id) + def get_collaborator!(id, opts \\ []) do + Collaborator + |> apply_filters(opts) + |> Repo.get!(id) + end @doc """ Gets a single collaborator. @@ -251,6 +296,24 @@ defmodule Atomic.Departments do |> Repo.update() end + @doc """ + Accepts a collaborator. + + ## Examples + + iex> accept_collaborator(collaborator) + {:ok, %Collaborator{}} + + iex> accept_collaborator(collaborator) + {:error, %Ecto.Changeset{}} + + """ + def accept_collaborator(%Collaborator{} = collaborator) do + collaborator + |> Collaborator.changeset(%{accepted: true, accepted_at: NaiveDateTime.utc_now()}) + |> Repo.update() + end + @doc """ Deletes a collaborator. @@ -280,6 +343,21 @@ defmodule Atomic.Departments do Collaborator.changeset(collaborator, attrs) end + @doc """ + Returns a paginated list of collaborators. + + ## Examples + + iex> list_display_collaborators() + [%Collaborator{}, ...] + + """ + def list_display_collaborators(%{} = flop, opts \\ []) do + Collaborator + |> apply_filters(opts) + |> Flop.validate_and_run(flop, for: Collaborator) + end + @doc """ Returns the list of collaborators belonging to a department. @@ -295,4 +373,114 @@ defmodule Atomic.Departments do |> where([c], c.department_id == ^id) |> Repo.all() end + + @doc """ + Updates a department banner. + + ## Examples + + iex> update_department_banner(department, %{field: new_value}) + {:ok, %Department{}} + + iex> update_department_banner(department, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_department_banner(%Department{} = department, attrs) do + department + |> Department.banner_changeset(attrs) + |> Repo.update() + end + + @doc """ + Get all admins of an organization that are collaborators of a department. + + ## Examples + + iex> get_admin_collaborators(department) + [%User{}, ...] + + """ + def get_admin_collaborators(%Department{} = department) do + User + |> join(:inner, [u], c in assoc(u, :collaborators)) + |> where([u, c], c.department_id == ^department.id and c.accepted == true) + |> join(:inner, [u, c], m in assoc(u, :memberships)) + |> where( + [u, c, m], + m.organization_id == ^department.organization_id and m.role in [:admin, :owner] + ) + |> Repo.all() + end + + @doc """ + Request collaborator access and send email. + + ## Examples + + iex> request_collaborator_access(user, department) + {:ok, %Collaborator{}} + + iex> request_collaborator_access(user, department) + {:error, %Ecto.Changeset{}} + + """ + def request_collaborator_access(%User{} = user, %Department{} = department) do + case create_collaborator(%{department_id: department.id, user_id: user.id}) do + {:ok, %Collaborator{} = collaborator} -> + DepartmentEmails.send_collaborator_request_email( + collaborator |> Repo.preload(:user), + department, + Helpers.department_show_path( + AtomicWeb.Endpoint, + :edit_collaborator, + department.organization_id, + department, + collaborator, + tab: "collaborators" + ), + to: get_admin_collaborators(department) |> Enum.map(& &1.email) + ) + + {:ok, collaborator} + + error -> + error + end + end + + @doc """ + Accept collaborator request and send email. + + ## Examples + + iex> accept_collaborator_request(collaborator) + {:ok, %Collaborator{}} + + iex> accept_collaborator_request(collaborator) + {:error, %Ecto.Changeset{}} + + """ + def accept_collaborator_request(%Collaborator{} = collaborator) do + collaborator + |> Repo.preload(department: [:organization]) + |> accept_collaborator() + |> case do + {:ok, collaborator} -> + DepartmentEmails.send_collaborator_accepted_email( + collaborator, + collaborator.department, + Helpers.department_show_path( + AtomicWeb.Endpoint, + :show, + collaborator.department.organization, + collaborator.department + ), + to: collaborator.user.email + ) + + error -> + error + end + end end diff --git a/lib/atomic/organizations/collaborator.ex b/lib/atomic/organizations/collaborator.ex index 5872afe29..704b23ee1 100644 --- a/lib/atomic/organizations/collaborator.ex +++ b/lib/atomic/organizations/collaborator.ex @@ -7,14 +7,29 @@ defmodule Atomic.Organizations.Collaborator do alias Atomic.Accounts.User alias Atomic.Organizations.Department - @required_fields ~w(user_id department_id)a - @optional_fields ~w(accepted)a + @required_fields ~w(user_id department_id accepted)a + @optional_fields ~w(accepted_at)a + + @derive { + Flop.Schema, + default_limit: 7, + filterable: [:accepted], + sortable: [:collaborator_name, :inserted_at, :updated_at], + default_order: %{ + order_by: [:inserted_at], + order_directions: [:desc] + }, + join_fields: [ + collaborator_name: [binding: :user, field: :name, path: [:user, :name]] + ] + } schema "collaborators" do belongs_to :user, User belongs_to :department, Department field :accepted, :boolean, default: false + field :accepted_at, :naive_datetime timestamps() end diff --git a/lib/atomic/organizations/department.ex b/lib/atomic/organizations/department.ex index 8b89a4c5e..831e7d012 100644 --- a/lib/atomic/organizations/department.ex +++ b/lib/atomic/organizations/department.ex @@ -6,11 +6,14 @@ defmodule Atomic.Organizations.Department do alias Atomic.Organizations.Organization @required_fields ~w(name organization_id)a - @optional_fields ~w(description)a + @optional_fields ~w(description collaborator_applications archived)a schema "departments" do field :name, :string field :description, :string + field :banner, Atomic.Uploaders.Banner.Type + field :collaborator_applications, :boolean, default: false + field :archived, :boolean, default: false belongs_to :organization, Organization, on_replace: :delete_if_exists @@ -22,4 +25,9 @@ defmodule Atomic.Organizations.Department do |> cast(attrs, @required_fields ++ @optional_fields) |> validate_required(@required_fields) end + + def banner_changeset(department, attrs) do + department + |> cast_attachments(attrs, [:banner]) + end end diff --git a/lib/atomic/uploaders/banner.ex b/lib/atomic/uploaders/banner.ex new file mode 100644 index 000000000..b642d1eb1 --- /dev/null +++ b/lib/atomic/uploaders/banner.ex @@ -0,0 +1,12 @@ +defmodule Atomic.Uploaders.Banner do + @moduledoc """ + Uploader for department banners. + """ + use Atomic.Uploader + + alias Atomic.Organizations.Department + + def storage_dir(_version, {_file, %Department{} = scope}) do + "uploads/atomic/departments/#{scope.id}/banner" + end +end diff --git a/lib/atomic_web.ex b/lib/atomic_web.ex index f8fae7d86..53f6c7367 100644 --- a/lib/atomic_web.ex +++ b/lib/atomic_web.ex @@ -92,7 +92,6 @@ defmodule AtomicWeb do # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) import Phoenix.LiveView.Helpers import Phoenix.Component - import AtomicWeb.LiveHelpers # Import commonly used components unquote(components()) diff --git a/lib/atomic_web/components/avatar.ex b/lib/atomic_web/components/avatar.ex index 7331b1821..9dacd363a 100644 --- a/lib/atomic_web/components/avatar.ex +++ b/lib/atomic_web/components/avatar.ex @@ -6,6 +6,10 @@ defmodule AtomicWeb.Components.Avatar do attr :name, :string, required: true, doc: "The name of the entity associated with the avatar." + attr :auto_generate_initials, :boolean, + default: true, + doc: "Whether to automatically generate the initials from the name." + attr :type, :atom, values: [:user, :organization, :company], default: :user, @@ -42,7 +46,11 @@ defmodule AtomicWeb.Components.Avatar do <%= if @src do %> <% else %> - <%= extract_initials(@name) %> + <%= if @auto_generate_initials do %> + <%= extract_initials(@name) %> + <% else %> + <%= @name %> + <% end %> <% end %> """ diff --git a/lib/atomic_web/components/button.ex b/lib/atomic_web/components/button.ex index f6b909cbe..39350908a 100644 --- a/lib/atomic_web/components/button.ex +++ b/lib/atomic_web/components/button.ex @@ -58,10 +58,10 @@ defmodule AtomicWeb.Components.Button do attr :rest, :global, include: - ~w(csrf_token disabled download form href hreflang method name navigate patch referrerpolicy rel replace target type value), + ~w(csrf_token disabled download form href hreflang method name navigate patch referrerpolicy rel replace target type value autofocus tabindex), doc: "Arbitrary HTML or phx attributes." - slot :inner_block, required: true, doc: "Slot for the body content of the page." + slot :inner_block, required: true, doc: "Slot for the content of the button." def button(assigns) do assigns diff --git a/lib/atomic_web/components/forms.ex b/lib/atomic_web/components/forms.ex index ea357107d..2b224f0f9 100644 --- a/lib/atomic_web/components/forms.ex +++ b/lib/atomic_web/components/forms.ex @@ -153,9 +153,7 @@ defmodule AtomicWeb.Components.Forms do <%= @label %> - + <.field_error :for={msg <- @errors}><%= msg %> <.field_help_text help_text={@help_text} /> diff --git a/lib/atomic_web/components/gradient.ex b/lib/atomic_web/components/gradient.ex new file mode 100644 index 000000000..bb0cb1e42 --- /dev/null +++ b/lib/atomic_web/components/gradient.ex @@ -0,0 +1,54 @@ +defmodule AtomicWeb.Components.Gradient do + @moduledoc """ + Generates a random gradient background or a predictable gradient background based on a seed that can be of any data type. + """ + use Phoenix.Component + + # List of gradients + @colors [ + {"#000046", "#1CB5E0"}, + {"#007991", "#78ffd6"}, + {"#30E8BF", "#FF8235"}, + {"#C33764", "#1D2671"}, + {"#34e89e", "#0f3443"}, + {"#44A08D", "#093637"}, + {"#DCE35B", "#45B649"}, + {"#c0c0aa", "#1cefff"}, + {"#ee0979", "#ff6a00"} + ] + + attr :class, :string, default: "", doc: "Additional classes to apply to the component." + attr :seed, :any, required: false, doc: "For predictable gradients." + + def gradient(assigns) do + {gradient_color_a, gradient_color_b} = + if Map.has_key?(assigns, :seed) do + generate_color(assigns.seed) + else + generate_color() + end + + assigns + |> assign(:gradient_color_a, gradient_color_a) + |> assign(:gradient_color_b, gradient_color_b) + |> render_gradient() + end + + defp render_gradient(assigns) do + ~H""" +
+ """ + end + + defp generate_color(seed) when is_binary(seed) do + # Convert the argument into an integer + index = :erlang.phash2(seed, length(@colors)) + + # Return the chosen color + Enum.at(@colors, index) + end + + defp generate_color do + Enum.random(@colors) + end +end diff --git a/lib/atomic_web/components/image_uploader.ex b/lib/atomic_web/components/image_uploader.ex index e843dd0a2..740220120 100644 --- a/lib/atomic_web/components/image_uploader.ex +++ b/lib/atomic_web/components/image_uploader.ex @@ -13,15 +13,12 @@ defmodule AtomicWeb.Components.ImageUploader do def render(assigns) do ~H"""
-
- - Image - +
<.live_file_input upload={@uploads.image} class="hidden" />
diff --git a/lib/atomic_web/components/modal.ex b/lib/atomic_web/components/modal.ex index 1ccc2d21e..8990f8c3f 100644 --- a/lib/atomic_web/components/modal.ex +++ b/lib/atomic_web/components/modal.ex @@ -41,7 +41,7 @@ defmodule AtomicWeb.Components.Modal do
<.focus_wrap id={"#{@id}-container"} phx-click-away={JS.exec("phx-cancel", to: "##{@id}")} class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition">
-
@@ -65,7 +65,6 @@ defmodule AtomicWeb.Components.Modal do ) |> show("##{id}-container") |> JS.add_class("overflow-hidden", to: "body") - |> JS.focus_first(to: "##{id}-content") end def hide_modal(js \\ %JS{}, id) do diff --git a/lib/atomic_web/components/table.ex b/lib/atomic_web/components/table.ex index 9c21ca8c8..d928d0210 100644 --- a/lib/atomic_web/components/table.ex +++ b/lib/atomic_web/components/table.ex @@ -29,7 +29,7 @@ defmodule AtomicWeb.Components.Table do <%= for item <- @items do %> <%= for col <- @col do %> - + <%= render_slot(col, item) %> <% end %> @@ -48,7 +48,7 @@ defmodule AtomicWeb.Components.Table do assign(assigns, :direction, order_direction(assigns.meta.flop.order_directions, index)) ~H""" - + <%= if is_sortable?(@field, @meta.schema) && is_filterable?(@field, @meta.schema) && should_filter(@field, @filter) do %>
<.link patch={build_sorting_query(@field, @meta)} class="mr-2 w-full"> diff --git a/lib/atomic_web/emails/department_emails.ex b/lib/atomic_web/emails/department_emails.ex new file mode 100644 index 000000000..7bbf4eb10 --- /dev/null +++ b/lib/atomic_web/emails/department_emails.ex @@ -0,0 +1,63 @@ +defmodule AtomicWeb.DepartmentEmails do + @moduledoc """ + A module to build department related emails. + """ + use Phoenix.Swoosh, view: AtomicWeb.EmailView + + alias Atomic.Mailer + + @doc """ + Sends an email to the collaborator when their application to join the department is accepted. + + ## Examples + + iex> send_collaborator_request_email(collaborator, department, department_show_url, to: email) + {:ok, email} + + iex> send_collaborator_request_email(collaborator, department, department_show_url, to: email) + {:error, reason} + """ + def send_collaborator_accepted_email(collaborator, department, department_show_url, to: email) do + base_email(to: email) + |> subject("[Atomic] You are now a collaborator of #{department.name}") + |> assign(:collaborator, collaborator) + |> assign(:department, department) + |> assign(:department_url, department_show_url) + |> render_body("collaborator_accepted.html") + |> Mailer.deliver() + end + + @doc """ + Sends an email to the department admins when a new collaborator requests to join the department. + + ## Examples + + iex> send_collaborator_request_email(collaborator, department, collaborator_review_url, + ...> to: emails + ...> ) + {:ok, email} + + iex> send_collaborator_request_email(collaborator, department, collaborator_review_url, + ...> to: emails + ...> ) + {:error, reason} + """ + def send_collaborator_request_email(collaborator, department, collaborator_review_url, + to: emails + ) do + base_email(to: emails) + |> subject("[Atomic] New collaborator request for #{department.name}") + |> assign(:collaborator, collaborator) + |> assign(:department, department) + |> assign(:collaborator_review_url, collaborator_review_url) + |> render_body("collaborator_request.html") + |> Mailer.deliver() + end + + defp base_email(to: email) do + new() + |> from({"Atomic", "noreply@atomic.cesium.pt"}) + |> to(email) + |> reply_to("caos@cesium.di.uminho.pt") + end +end diff --git a/lib/atomic_web/live/collaborator_live/form_component.ex b/lib/atomic_web/live/collaborator_live/form_component.ex new file mode 100644 index 000000000..09082e9a9 --- /dev/null +++ b/lib/atomic_web/live/collaborator_live/form_component.ex @@ -0,0 +1,212 @@ +defmodule AtomicWeb.CollaboratorLive.FormComponent do + use AtomicWeb, :live_component + + import AtomicWeb.Components.{Avatar, Badge} + + alias Atomic.Departments + alias Phoenix.LiveView.JS + + @impl true + def render(assigns) do + ~H""" +
+

Collaborator

+ + <%= if !@collaborator.accepted do %> +

+ <%= gettext("%{user_name} has requested to be a collaborator of %{department_name}.", user_name: extract_first_name(@collaborator.user.name), department_name: @department.name) %> +

+ <% end %> + +
+ <.link navigate={Routes.profile_show_path(@socket, :show, @collaborator.user)} class="mt-4 flex outline-none"> + <.avatar color={:light_gray} name={@collaborator.user.name} /> +
+

<%= @collaborator.user.name %>

+

@<%= @collaborator.user.slug %>

+
+ + <%= if @collaborator.accepted do %> + <.badge variant={:outline} color={:success} size={:md} class="my-5 select-none rounded-xl py-1 font-normal sm:ml-auto sm:py-0"> +

<%= gettext("Collaborator since %{accepted_at}", accepted_at: @collaborator.accepted_at) %>

+ + <% else %> + <.badge variant={:outline} color={:warning} size={:md} class="bg-yellow-300/5 my-5 select-none rounded-xl border-yellow-400 py-1 font-normal text-yellow-400 sm:ml-auto sm:py-0"> +

<%= gettext("Not accepted") %>

+ + <% end %> +
+ <%= if !@collaborator.accepted do %> +
+ <.icon class="my-auto h-5 w-5" name={:calendar} /> +

<%= gettext("Requested %{requested_at}", requested_at: relative_datetime(@collaborator.inserted_at)) %>

+
+ <% end %> + +
+ <%= if @collaborator.accepted do %> + <.button phx-click="delete" phx-target={@myself} size={:lg} icon={:x_circle} color={:white} full_width><%= gettext("Delete") %> + <% else %> + <.button phx-click="deny" phx-target={@myself} size={:lg} icon={:x_circle} color={:white} full_width><%= gettext("Deny") %> + <.button phx-click="allow" phx-target={@myself} size={:lg} icon={:check_circle} color={:white} full_width><%= gettext("Accept") %> + <% end %> +
+ + <.modal :if={@action_modal} id="action-confirm-modal" show on_cancel={JS.push("clear-action", target: @myself)}> +
+

+ <%= display_action_goal_confirm_title(@action_modal) %> +

+

+ <%= display_action_goal_confirm_description(@action_modal, @department) %> +

+
+ <.button phx-click="clear-action" class="mr-2" phx-target={@myself} size={:lg} icon={:x_circle} color={:white} full_width><%= gettext("Cancel") %> + <.button + phx-click="confirm" + class="ml-2" + phx-target={@myself} + size={:lg} + icon={:check_circle} + color={ + if @action_modal in [:delete_collaborator, :deny_request] do + :danger + else + :success + end + } + full_width + > + <%= gettext("Confirm") %> + +
+
+ +
+ """ + end + + @impl true + def update(%{collaborator: collaborator} = assigns, socket) do + changeset = Departments.change_collaborator(collaborator) + + {:ok, + socket + |> assign(assigns) + |> assign(:action_modal, nil) + |> assign(:changeset, changeset)} + end + + defp deny_collaborator_request(socket) do + case Departments.delete_collaborator(socket.assigns.collaborator) do + {:ok, _} -> + notify_result(socket, :success, gettext("Collaborator request denied.")) + + _ -> + notify_result( + socket, + :error, + gettext("Could not deny the collaborator request. Please try again later.") + ) + end + end + + defp delete_collaborator(socket) do + case Departments.delete_collaborator(socket.assigns.collaborator) do + {:ok, _} -> + notify_result(socket, :success, gettext("Collaborator removed successfully.")) + + _ -> + notify_result( + socket, + :error, + gettext("Could not delete the collaborator. Please try again later.") + ) + end + end + + defp accept_collaborator_request(socket) do + case Departments.accept_collaborator_request(socket.assigns.collaborator) do + {:ok, _} -> + notify_result(socket, :success, gettext("Collaborator accepted successfully.")) + + _ -> + notify_result( + socket, + :error, + gettext("Could not accept the collaborator. Please try again later.") + ) + end + end + + defp notify_result(socket, status, message) do + send(self(), {:change_collaborator, %{status: status, message: message}}) + {:noreply, assign(socket, :action_modal, nil)} + end + + @impl true + def handle_event("confirm", _, socket) do + case socket.assigns.action_modal do + :confirm_request -> accept_collaborator_request(socket) + :deny_request -> deny_collaborator_request(socket) + :delete_collaborator -> delete_collaborator(socket) + end + end + + @impl true + def handle_event("clear-action", _, socket) do + {:noreply, + socket + |> assign(:action_modal, nil)} + end + + @impl true + def handle_event("allow", _, socket) do + {:noreply, + socket + |> assign(:action_modal, :confirm_request)} + end + + @impl true + def handle_event("deny", _, socket) do + {:noreply, + socket + |> assign(:action_modal, :deny_request)} + end + + @impl true + def handle_event("delete", _, socket) do + {:noreply, + socket + |> assign(:action_modal, :delete_collaborator)} + end + + defp display_action_goal_confirm_title(action) do + case action do + :confirm_request -> + gettext("Are you sure you want to accept this request?") + + :deny_request -> + gettext("Are you sure you want to deny this request?") + + :delete_collaborator -> + gettext("Are you sure you want to remove this person from the department?") + end + end + + defp display_action_goal_confirm_description(action, department) do + case action do + :confirm_request -> + gettext("If you change your mind you can always remove this person later.") + + :deny_request -> + gettext("If you deny this request, this person will not get access to the department.") + + :delete_collaborator -> + gettext( + "If you remove this person, they will no longer have access to %{department_name}.", + department_name: department.name + ) + end + end +end diff --git a/lib/atomic_web/live/department_live/components/department_card.ex b/lib/atomic_web/live/department_live/components/department_card.ex new file mode 100644 index 000000000..f67f711f7 --- /dev/null +++ b/lib/atomic_web/live/department_live/components/department_card.ex @@ -0,0 +1,43 @@ +defmodule AtomicWeb.DepartmentLive.Components.DepartmentCard do + @moduledoc false + use AtomicWeb, :component + + import AtomicWeb.Components.{Avatar, Badge, Gradient} + + attr :department, :map, required: true, doc: "The department to display." + attr :collaborators, :list, required: true, doc: "The list of collaborators in the department." + + def department_card(assigns) do + ~H""" +
+
+ <%= if @department.banner do %> + + <% else %> + <.gradient seed={@department.id} class="rounded-t-lg" /> + <% end %> +
+
+
+

<%= @department.name %>

+ <.badge :if={@department.archived} variant={:outline} color={:warning} size={:md} class="bg-yellow-300/5 select-none rounded-xl border-yellow-400 py-1 font-normal text-yellow-400 sm:ml-auto sm:py-0"> +

<%= gettext("Archived") %>

+ +
+
    + <%= for person <- @collaborators |> Enum.take(4) do %> +
  • + <.avatar name={person.user.name} size={:xs} color={:light_gray} class="ring-1 ring-white" /> +
  • + <% end %> + <%= if length(@collaborators) > 4 do %> +
  • + <.avatar name={"+#{length(@collaborators) - 4}"} size={:xs} auto_generate_initials={false} color={:light_gray} class="ring-1 ring-white" /> +
  • + <% end %> +
+
+
+ """ + end +end diff --git a/lib/atomic_web/live/department_live/edit.ex b/lib/atomic_web/live/department_live/edit.ex index ec690bbca..d3dc6634c 100644 --- a/lib/atomic_web/live/department_live/edit.ex +++ b/lib/atomic_web/live/department_live/edit.ex @@ -3,6 +3,8 @@ defmodule AtomicWeb.DepartmentLive.Edit do use AtomicWeb, :live_view alias Atomic.Departments + alias Atomic.Organizations.Department + alias Phoenix.LiveView.JS @impl true def mount(_params, _session, socket) do @@ -10,14 +12,111 @@ defmodule AtomicWeb.DepartmentLive.Edit do end @impl true - def handle_params(%{"organization_id" => organization_id, "id" => id} = _params, _, socket) do + def handle_params( + %{"organization_id" => organization_id, "id" => id} = _params, + _, + %{:assigns => %{:live_action => :edit}} = socket + ) do department = Departments.get_department!(id) {:noreply, socket |> assign(:organization_id, organization_id) + |> assign(:action, nil) |> assign(:current_page, :departments) |> assign(:page_title, gettext("Edit Department")) |> assign(:department, department)} end + + @impl true + def handle_params( + %{"organization_id" => organization_id} = _params, + _, + %{:assigns => %{:live_action => :new}} = socket + ) do + {:noreply, + socket + |> assign(:organization_id, organization_id) + |> assign(:action, nil) + |> assign(:current_page, :departments) + |> assign(:page_title, gettext("New Department")) + |> assign(:department, %Department{organization_id: organization_id})} + end + + @impl true + def handle_event("set-action", %{"action" => action}, socket) do + {:noreply, assign(socket, :action, action |> String.to_atom())} + end + + @impl true + def handle_event("clear-action", _params, socket) do + {:noreply, assign(socket, :action, nil)} + end + + @impl true + def handle_event("confirm-action", _params, %{assigns: %{action: :archive}} = socket) do + Departments.archive_department(socket.assigns.department) + + {:noreply, + socket + |> push_navigate( + to: Routes.department_index_path(socket, :index, socket.assigns.organization_id) + ) + |> put_flash(:info, gettext("Department archived successfully"))} + end + + @impl true + def handle_event("confirm-action", _params, %{assigns: %{action: :unarchive}} = socket) do + Departments.unarchive_department(socket.assigns.department) + + {:noreply, + socket + |> push_navigate( + to: Routes.department_index_path(socket, :index, socket.assigns.organization_id) + ) + |> put_flash(:info, gettext("Department unarchived successfully"))} + end + + @impl true + def handle_event("confirm-action", _params, %{assigns: %{action: :delete}} = socket) do + Departments.delete_department(socket.assigns.department) + + {:noreply, + socket + |> push_navigate( + to: Routes.department_index_path(socket, :index, socket.assigns.organization_id) + ) + |> put_flash(:info, gettext("Department deleted successfully"))} + end + + defp display_action_goal_confirm_title(action) do + case action do + :archive -> + gettext("Are you sure you want to archive this department?") + + :unarchive -> + gettext("Are you sure you want to unarchive this department?") + + :delete -> + gettext("Are you sure you want do delete this department?") + end + end + + defp display_action_goal_confirm_description(action, department) do + case action do + :archive -> + gettext("You can always change you mind later and make it public again.") + + :unarchive -> + gettext( + "This will make it so that any person can view this department and their collaborators." + ) + + :delete -> + gettext( + "This will permanently delete %{department_name}, this action is not reversible.", + department_name: department.name + ) + end + end end diff --git a/lib/atomic_web/live/department_live/edit.html.heex b/lib/atomic_web/live/department_live/edit.html.heex index e72e54592..383119840 100644 --- a/lib/atomic_web/live/department_live/edit.html.heex +++ b/lib/atomic_web/live/department_live/edit.html.heex @@ -1 +1,75 @@ -<.live_component module={AtomicWeb.DepartmentLive.FormComponent} id={@department.id} organization={@current_organization} title={@page_title} action={@live_action} department={@department} return_to={Routes.department_show_path(@socket, :show, @organization_id, @department)} /> +<.page title={ + if @live_action in [:new] do + gettext("New Department") + else + gettext("Edit %{department_name}", department_name: @department.name) + end +}> + <:actions> + <%= if @live_action in [:edit] do %> +
+ <%= if !@department.archived do %> + <.button color={:white} icon={:archive_box} phx-click="set-action" phx-value-action={:archive}> + <%= gettext("Archive") %> + + <.button color={:white} icon={:trash} disabled title={gettext("You can only delete a department after it has been archived.")}> + <%= gettext("Delete") %> + + <% else %> + <.button color={:white} icon={:archive_box_x_mark} phx-click="set-action" phx-value-action={:unarchive}> + <%= gettext("Unarchive") %> + + <.button color={:white} icon={:trash} phx-click="set-action" phx-value-action={:delete}> + <%= gettext("Delete") %> + + <% end %> +
+ <% end %> + +
+ <.live_component + module={AtomicWeb.DepartmentLive.FormComponent} + id="department-edit" + organization={@current_organization} + title={@page_title} + action={@live_action} + department={@department} + return_to={ + if @live_action in [:new] do + Routes.department_index_path(@socket, :index, @organization_id) + else + Routes.department_show_path(@socket, :show, @organization_id, @department) + end + } + /> +
+ +<.modal :if={@action} id="action-modal" show on_cancel={JS.push("clear-action")}> +
+

+ <%= display_action_goal_confirm_title(@action) %> +

+

+ <%= display_action_goal_confirm_description(@action, @department) %> +

+
+ <.button phx-click="clear-action" class="mr-2" size={:lg} icon={:x_circle} color={:white} full_width><%= gettext("Cancel") %> + <.button + phx-click="confirm-action" + class="mr-2" + size={:lg} + icon={:check_circle} + color={ + if @action == :delete do + :danger + else + :white + end + } + full_width + > + <%= gettext("Confirm") %> + +
+
+ diff --git a/lib/atomic_web/live/department_live/form_component.ex b/lib/atomic_web/live/department_live/form_component.ex index 32cf1b8a5..144cbc5ba 100644 --- a/lib/atomic_web/live/department_live/form_component.ex +++ b/lib/atomic_web/live/department_live/form_component.ex @@ -2,6 +2,39 @@ defmodule AtomicWeb.DepartmentLive.FormComponent do use AtomicWeb, :live_component alias Atomic.Departments + alias AtomicWeb.Components.ImageUploader + + import AtomicWeb.Components.Forms + + @impl true + def render(assigns) do + ~H""" +
+ <.form :let={f} for={@changeset} id="department-form" phx-target={@myself} phx-change="validate" phx-submit="save"> +

<%= gettext("General") %>

+
+ <.field type="text" help_text={gettext("The name of the department")} field={f[:name]} placeholder="Name" required /> + <.field type="textarea" help_text={gettext("A brief description of the department")} field={f[:description]} placeholder="Description" /> + <.field type="checkbox" help_text={gettext("Allow any user to apply to be a collaborator in this department")} field={f[:collaborator_applications]} /> +
+

<%= gettext("Personalization") %>

+
+
+ <%= label(f, :banner, class: "department-form_description") %> +

<%= gettext("The banner of the department (2055×256px for best display)") %>

+
+
+ <.live_component module={ImageUploader} id="uploader" uploads={@uploads} target={@myself} /> +
+
+ +
+ <.button size={:md} color={:white} icon={:cube} type="submit"><%= gettext("Save Changes") %> +
+ +
+ """ + end @impl true def update(%{department: department} = assigns, socket) do @@ -9,6 +42,7 @@ defmodule AtomicWeb.DepartmentLive.FormComponent do {:ok, socket + |> allow_upload(:image, accept: Atomic.Uploader.extensions_whitelist(), max_entries: 1) |> assign(assigns) |> assign(:changeset, changeset)} end @@ -23,12 +57,17 @@ defmodule AtomicWeb.DepartmentLive.FormComponent do {:noreply, assign(socket, :changeset, changeset)} end + @impl true def handle_event("save", %{"department" => department_params}, socket) do save_department(socket, socket.assigns.action, department_params) end defp save_department(socket, :edit, department_params) do - case Departments.update_department(socket.assigns.department, department_params) do + case Departments.update_department( + socket.assigns.department, + department_params, + &consume_image_data(socket, &1) + ) do {:ok, _department} -> {:noreply, socket @@ -44,7 +83,7 @@ defmodule AtomicWeb.DepartmentLive.FormComponent do department_params = Map.put(department_params, "organization_id", socket.assigns.organization.id) - case Departments.create_department(department_params) do + case Departments.create_department(department_params, &consume_image_data(socket, &1)) do {:ok, _department} -> {:noreply, socket @@ -55,4 +94,23 @@ defmodule AtomicWeb.DepartmentLive.FormComponent do {:noreply, assign(socket, changeset: changeset)} end end + + defp consume_image_data(socket, department) do + consume_uploaded_entries(socket, :image, fn %{path: path}, entry -> + Departments.update_department_banner(department, %{ + "banner" => %Plug.Upload{ + content_type: entry.client_type, + filename: entry.client_name, + path: path + } + }) + end) + |> case do + [{:ok, department}] -> + {:ok, department} + + _errors -> + {:ok, department} + end + end end diff --git a/lib/atomic_web/live/department_live/form_component.html.heex b/lib/atomic_web/live/department_live/form_component.html.heex deleted file mode 100644 index f68ee4172..000000000 --- a/lib/atomic_web/live/department_live/form_component.html.heex +++ /dev/null @@ -1,17 +0,0 @@ -
-

<%= @title %>

- - <.form :let={f} for={@changeset} id="department-form" phx-target={@myself} phx-change="validate" phx-submit="save"> - <%= label(f, :name) %> - <%= text_input(f, :name) %> - <%= error_tag(f, :name) %> - - <%= label(f, :description) %> - <%= text_input(f, :description) %> - <%= error_tag(f, :description) %> - -
- <%= submit("Save", phx_disable_with: "Saving...") %> -
- -
diff --git a/lib/atomic_web/live/department_live/index.ex b/lib/atomic_web/live/department_live/index.ex index 7511d90c3..a5396b57a 100644 --- a/lib/atomic_web/live/department_live/index.ex +++ b/lib/atomic_web/live/department_live/index.ex @@ -1,8 +1,8 @@ defmodule AtomicWeb.DepartmentLive.Index do use AtomicWeb, :live_view - import AtomicWeb.Components.Empty - import AtomicWeb.Components.Button + import AtomicWeb.Components.{Button, Empty} + import AtomicWeb.DepartmentLive.Components.DepartmentCard alias Atomic.Accounts alias Atomic.Departments @@ -16,9 +16,8 @@ defmodule AtomicWeb.DepartmentLive.Index do @impl true def handle_params(%{"organization_id" => organization_id}, _, socket) do organization = Organizations.get_organization!(organization_id) - - departments = - Departments.list_departments_by_organization_id(organization_id, preloads: [:organization]) + has_permissions = has_permissions?(socket, organization_id) + departments = get_department_and_collaborators(organization_id, has_permissions) {:noreply, socket @@ -27,7 +26,7 @@ defmodule AtomicWeb.DepartmentLive.Index do |> assign(:organization, organization) |> assign(:departments, departments) |> assign(:empty?, Enum.empty?(departments)) - |> assign(:has_permissions?, has_permissions?(socket, organization_id))} + |> assign(:has_permissions?, has_permissions)} end defp has_permissions?(socket, organization_id) do @@ -37,4 +36,36 @@ defmodule AtomicWeb.DepartmentLive.Index do organization_id ) end + + defp get_department_and_collaborators(organization_id, false) do + Departments.list_departments_by_organization_id(organization_id, + preloads: [:organization], + where: [archived: false] + ) + |> Enum.map(fn department -> + collaborators = + department.id + |> Departments.list_collaborators_by_department_id( + preloads: [:user], + where: [accepted: true] + ) + + {department, collaborators} + end) + end + + defp get_department_and_collaborators(organization_id, true) do + Departments.list_departments_by_organization_id(organization_id, preloads: [:organization]) + |> Enum.sort_by(& &1.archived) + |> Enum.map(fn department -> + collaborators = + department.id + |> Departments.list_collaborators_by_department_id( + preloads: [:user], + where: [accepted: true] + ) + + {department, collaborators} + end) + end end diff --git a/lib/atomic_web/live/department_live/index.html.heex b/lib/atomic_web/live/department_live/index.html.heex index 18cb3e38a..30c532038 100644 --- a/lib/atomic_web/live/department_live/index.html.heex +++ b/lib/atomic_web/live/department_live/index.html.heex @@ -1,42 +1,21 @@ <.page title="Departments"> <:actions> <%= if not @empty? and @has_permissions? do %> - <.button navigate={Routes.department_new_path(@socket, :new, @current_organization)}> + <.button patch={Routes.department_edit_path(@socket, :new, @current_organization)} icon={:plus}> <%= gettext("New") %> <% end %> - -
-
-
-
-
- -
-
-
-
-
<%= if @empty? and @has_permissions? do %>
- <.empty_state url={Routes.department_new_path(@socket, :new, @organization)} placeholder="department" /> + <.empty_state url={Routes.department_edit_path(@socket, :new, @current_organization)} placeholder="department" />
<% else %> -
- <%= for department <- @departments do %> - <.link navigate={Routes.department_show_path(@socket, :show, @organization, department)} class="col-span-1 flex flex-col divide-y divide-zinc-200 rounded-lg bg-white text-center shadow"> -
-

<%= department.name %>

-
+
+ <%= for {department, collaborators} <- @departments do %> + <.link navigate={Routes.department_show_path(@socket, :show, @organization, department)}> + <.department_card department={department} collaborators={collaborators} /> <% end %>
diff --git a/lib/atomic_web/live/department_live/new.ex b/lib/atomic_web/live/department_live/new.ex deleted file mode 100644 index 25c5b93f0..000000000 --- a/lib/atomic_web/live/department_live/new.ex +++ /dev/null @@ -1,20 +0,0 @@ -defmodule AtomicWeb.DepartmentLive.New do - @moduledoc false - use AtomicWeb, :live_view - - alias Atomic.Organizations.Department - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"organization_id" => organization_id}, _, socket) do - {:noreply, - socket - |> assign(:current_page, :department) - |> assign(:page_title, gettext("New Department")) - |> assign(:department, %Department{organization_id: organization_id})} - end -end diff --git a/lib/atomic_web/live/department_live/new.html.heex b/lib/atomic_web/live/department_live/new.html.heex deleted file mode 100644 index 595297a7f..000000000 --- a/lib/atomic_web/live/department_live/new.html.heex +++ /dev/null @@ -1 +0,0 @@ -<.live_component module={AtomicWeb.DepartmentLive.FormComponent} id={:new} organization={@current_organization} title={@page_title} action={@live_action} department={@department} return_to={Routes.department_index_path(@socket, :index, @current_organization)} /> diff --git a/lib/atomic_web/live/department_live/show.ex b/lib/atomic_web/live/department_live/show.ex index c62acd91d..85f88883c 100644 --- a/lib/atomic_web/live/department_live/show.ex +++ b/lib/atomic_web/live/department_live/show.ex @@ -1,10 +1,13 @@ defmodule AtomicWeb.DepartmentLive.Show do use AtomicWeb, :live_view + import AtomicWeb.Components.{Avatar, Dropdown, Gradient, Table, Pagination, Modal} + alias Atomic.Accounts alias Atomic.Departments alias Atomic.Organizations alias Atomic.Organizations.Collaborator + alias Phoenix.LiveView.JS @impl true def mount(_params, _session, socket) do @@ -12,22 +15,112 @@ defmodule AtomicWeb.DepartmentLive.Show do end @impl true - def handle_params(%{"organization_id" => organization_id, "id" => id}, _, socket) do + def handle_params(params, _uri, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :show, %{"organization_id" => organization_id, "id" => id} = params) do organization = Organizations.get_organization!(organization_id) department = Departments.get_department!(id) + has_permissions = has_permissions?(socket, organization_id) + + if department.archived && !has_permissions do + raise Ecto.NoResultsError, queryable: Atomic.Organizations.Department + else + socket + |> assign(:current_page, :departments) + |> assign(:current_view, current_view(socket, params)) + |> assign(:page_title, department.name) + |> assign(:organization, organization) + |> assign(:department, department) + |> assign(:params, params) + |> assign(:current_collaborator, maybe_put_collaborator(socket, department.id)) + |> assign(list_collaborators(department.id, params, has_permissions)) + |> assign( + :all_collaborators, + Departments.list_collaborators_by_department_id(department.id, + preloads: [:user], + where: [accepted: true] + ) + ) + |> assign(:has_permissions?, has_permissions) + end + end + + defp apply_action( + socket, + :edit_collaborator, + %{ + "organization_id" => organization_id, + "id" => department_id, + "collaborator_id" => collaborator_id + } = params + ) do + organization = Organizations.get_organization!(organization_id) + department = Departments.get_department!(department_id) + collaborator = Departments.get_collaborator!(collaborator_id, preloads: [:user]) + + has_permissions = has_permissions?(socket, organization_id) + + socket + |> assign(:current_page, :departments) + |> assign(:current_view, current_view(socket, params)) + |> assign(:page_title, department.name) + |> assign(:organization, organization) + |> assign(:department, department) + |> assign(:collaborator, collaborator) + |> assign(list_collaborators(department.id, params, has_permissions)) + |> assign( + :all_collaborators, + Departments.list_collaborators_by_department_id(department.id, + preloads: [:user], + where: [accepted: true] + ) + ) + |> assign(:params, params) + |> assign(:has_permissions?, has_permissions) + end + + defp list_collaborators(id, params, has_permissions) do + if has_permissions do + # If the user has permissions, list all collaborators + list_collaborators_paginated(params, department_id: id) + else + # If the user does not have permissions, list only accepted collaborators + list_collaborators_paginated(params, department_id: id, accepted: true) + end + end + + defp list_collaborators_paginated(params, filter) do + case Departments.list_display_collaborators(params, + where: filter, + preloads: [:user] + ) do + {:ok, {collaborators, meta}} -> + %{collaborators: collaborators, meta: meta} + + {:error, flop} -> + %{collaborators: [], meta: flop} + end + end + + @impl true + def handle_info({:change_collaborator, %{status: status, message: message}}, socket) do {:noreply, socket - |> assign(:current_page, :departments) - |> assign(:page_title, department.name) - |> assign(:organization, organization) - |> assign(:department, department) - |> assign(:collaborator, maybe_put_collaborator(socket, department.id)) - |> assign( - :collaborators, - Departments.list_collaborators_by_department_id(department.id, preloads: [:user]) - ) - |> assign(:has_permissions?, has_permissions?(socket, organization_id))} + |> put_flash(status, message) + |> assign(:live_action, :show) + |> push_patch( + to: + Routes.department_show_path( + socket, + :show, + socket.assigns.organization, + socket.assigns.department, + Map.delete(socket.assigns.params, "collaborator_id") + ) + )} end @impl true @@ -35,7 +128,7 @@ defmodule AtomicWeb.DepartmentLive.Show do department = socket.assigns.department user = socket.assigns.current_user - case Departments.create_collaborator(%{department_id: department.id, user_id: user.id}) do + case Departments.request_collaborator_access(user, department) do {:ok, %Collaborator{} = collaborator} -> {:noreply, socket @@ -91,4 +184,8 @@ defmodule AtomicWeb.DepartmentLive.Show do organization_id ) end + + defp current_view(_socket, params) when is_map_key(params, "tab"), do: params["tab"] + + defp current_view(_socket, _params), do: "show" end diff --git a/lib/atomic_web/live/department_live/show.html.heex b/lib/atomic_web/live/department_live/show.html.heex index a0bb861e0..1802faa60 100644 --- a/lib/atomic_web/live/department_live/show.html.heex +++ b/lib/atomic_web/live/department_live/show.html.heex @@ -1,102 +1,252 @@ -
-
-
-
-
-
-

- <%= @department.name %> -

+
+ +
+ <%= if @department.banner do %> + + <% else %> + <.gradient seed={@department.id} class="object-cover" /> + <% end %> +
+
+
+
+
+
+ +
+

+ <%= @department.name %> +

+ <.link navigate={Routes.organization_show_path(@socket, :show, @organization)}> +

+ @<%= @organization.name %> +

+ +
+ + +
+ <%= if @current_view == "show" do %> + + <%= if !@current_collaborator do %> + <%= if @department.collaborator_applications do %> + <.button + phx-click={ + "#{if @is_authenticated? do + "collaborate" + else + "must-login" + end}" + } + color={:white} + icon={:user_plus} + icon_variant={:solid} + title={gettext("Collaborate")} + > + + <% end %> + <% else %> + <%= if ! @current_collaborator.accepted do %> + <.button color={:white} icon={:user_plus} icon_variant={:solid} aria-label={gettext("You have requested to collaborate with this department. Please wait for the department owner to accept your request.")} disabled> + <% end %> + <% end %> + <.dropdown + id="actions" + items={ + [ + %{ + name: gettext("Collaborators"), + link: + Routes.department_show_path( + @socket, + :show, + @organization.id, + @department.id, + tab: "collaborators" + ), + icon: :user_group + } + ] ++ + if @has_permissions? || (@current_collaborator && @current_collaborator.accepted) do + [%{name: gettext("Edit"), link: Routes.department_edit_path(@socket, :edit, @organization, @department, @params), icon: :pencil}] + else + [] + end + } + > + <:wrapper> + <.button icon_variant={:solid} color={:white} icon={:ellipsis_horizontal}> + + + <% end %> +
+ + +
+
+ <%= if @current_view == "show" do %> + +
+ <%= @department.description %>
- <%= if ! @collaborator do %> -
- + <%= gettext("View all collaborators") %> +
- <% else %> - <%= if ! @collaborator.accepted do %> -
- +
+ <% end %> + <%= if @current_view == "collaborators" do %> + <%= if length(@collaborators) != 0 do %> + <%= if @has_permissions? do %> + +
+

Collaborators

+
+ <.table items={@collaborators} meta={@meta} filter={[]}> + <:col :let={collaborator} label="Name" field={:string}><%= collaborator.user.name %> + <:col :let={collaborator} label="Email" field={:string}><%= collaborator.user.email %> + <:col :let={collaborator} label="Phone number" field={:string}><%= collaborator.user.phone_number %> + <:col :let={collaborator} label="Accepted" field={:string}> + + + <:col :let={collaborator}> + <%= if collaborator.accepted do %> + <.button icon={:pencil} icon_variant={:solid} color={:white} full_width patch={Routes.department_show_path(@socket, :edit_collaborator, @organization, @department, collaborator, @params)}>Edit + <% else %> + <.button icon={:envelope} icon_variant={:solid} color={:white} full_width patch={Routes.department_show_path(@socket, :edit_collaborator, @organization, @department, collaborator, @params)}>Review + <% end %> + + +
+ <.pagination items={@collaborators} meta={@meta} params={@params} class="flex w-full items-center justify-between border border-t-0 pt-2" />
<% else %> -
-
- Collaborator -
+ +
+

Collaborators

+ <%= for collaborator <- @collaborators do %> +
+
+ <.avatar name={collaborator.user.name} /> +
+

<%= collaborator.user.name %>

+

@<%= collaborator.user.slug %>

+
+
+
+ <% end %> + <.pagination items={@collaborators} meta={@meta} params={@params} class="mt-2 flex w-full items-center justify-between" />
<% end %> <% end %> -
-
- -
-
- -
-
+ <% end %> +
- - <%= if length(@collaborators) != 0 do %> -
-
-
-
- - - - - - - - - - - - - - <%= for collaborator <- @collaborators do %> - - - - - - - - - - <% end %> - -
NameEmailPhone NumberAcceptedRequested AtUpdated At - Edit -
<%= collaborator.user.name %><%= collaborator.user.email %>Phone number<%= AtomicWeb.Helpers.capitalize_first_letter(collaborator.accepted) %><%= display_date(collaborator.inserted_at) %> <%= display_time(collaborator.inserted_at) %><%= display_date(collaborator.updated_at) %> <%= display_time(collaborator.updated_at) %> - <.link patch={Routes.department_index_path(@socket, :edit, @organization, @department)} class="button"> - - -
-
-
-
-
- <% end %>
+<.modal :if={@live_action in [:edit_collaborator]} id="edit-collaborator" show on_cancel={JS.patch(Routes.department_show_path(@socket, :show, @organization, @department, Map.delete(@params, "collaborator_id")))}> + <.live_component module={AtomicWeb.CollaboratorLive.FormComponent} id={@collaborator.id} title={@page_title} action={@live_action} collaborator={@collaborator} department={@department} /> + diff --git a/lib/atomic_web/live/speaker_live/index.ex b/lib/atomic_web/live/speaker_live/index.ex index e9f6ba6ca..44d975848 100644 --- a/lib/atomic_web/live/speaker_live/index.ex +++ b/lib/atomic_web/live/speaker_live/index.ex @@ -1,6 +1,8 @@ defmodule AtomicWeb.SpeakerLive.Index do use AtomicWeb, :live_view + import AtomicWeb.Components.Modal + alias Atomic.Activities alias Atomic.Activities.Speaker alias Phoenix.LiveView.JS diff --git a/lib/atomic_web/live/speaker_live/show.ex b/lib/atomic_web/live/speaker_live/show.ex index 518db2a7a..04caef12a 100644 --- a/lib/atomic_web/live/speaker_live/show.ex +++ b/lib/atomic_web/live/speaker_live/show.ex @@ -1,6 +1,8 @@ defmodule AtomicWeb.SpeakerLive.Show do use AtomicWeb, :live_view + import AtomicWeb.Components.Modal + alias Atomic.Activities alias Phoenix.LiveView.JS diff --git a/lib/atomic_web/router.ex b/lib/atomic_web/router.ex index c8fb62c28..9e319a191 100644 --- a/lib/atomic_web/router.ex +++ b/lib/atomic_web/router.ex @@ -92,8 +92,9 @@ defmodule AtomicWeb.Router do scope "/departments" do pipe_through :confirm_department_association - live "/new", DepartmentLive.New, :new - live "/:id/edit", DepartmentLive.Index, :edit + live "/new", DepartmentLive.Edit, :new + live "/:id/edit", DepartmentLive.Edit, :edit + live "/:id/collaborators/:collaborator_id/edit", DepartmentLive.Show, :edit_collaborator end scope "/partners" do diff --git a/lib/atomic_web/templates/email/collaborator_accepted.html.eex b/lib/atomic_web/templates/email/collaborator_accepted.html.eex new file mode 100644 index 000000000..e56cfa7cb --- /dev/null +++ b/lib/atomic_web/templates/email/collaborator_accepted.html.eex @@ -0,0 +1,18 @@ + + + + + + + +
+

Hello <%= @collaborator.user.name %>,

+ +

Your application to be a collaborator of <%= @department.name %> has been accepted!

+ +

Now you can access the department's projects and tasks >here.

+ +

This email has been automatically generated. Please, do not reply.

+
+ + diff --git a/lib/atomic_web/templates/email/collaborator_request.html.eex b/lib/atomic_web/templates/email/collaborator_request.html.eex new file mode 100644 index 000000000..eece55acc --- /dev/null +++ b/lib/atomic_web/templates/email/collaborator_request.html.eex @@ -0,0 +1,18 @@ + + + + + + + +
+

Hello,

+ +

<%= @collaborator.user.name %> has requested to be a collaborator of <%= @department.name %>!

+ +

This request can be reviewed >here.

+ +

This email has been automatically generated. Please, do not reply.

+
+ + diff --git a/lib/atomic_web/views/helpers.ex b/lib/atomic_web/views/helpers.ex index 318725c37..75bdce693 100644 --- a/lib/atomic_web/views/helpers.ex +++ b/lib/atomic_web/views/helpers.ex @@ -225,6 +225,31 @@ defmodule AtomicWeb.Helpers do end end + @doc """ + Return the first name of a name. + + ## Examples + + iex> extract_first_name("John Doe") + "John" + + iex> extract_first_name("John") + "John" + + iex> extract_first_name(nil) + "" + + """ + def extract_first_name(nil), do: "" + + def extract_first_name(name) do + name + |> String.split(" ") + |> Enum.filter(&String.match?(String.slice(&1, 0, 1), ~r/^\p{L}$/u)) + |> Enum.map(&String.capitalize/1) + |> hd() + end + @doc """ Slices a string if it is longer than the given length diff --git a/priv/repo/migrations/20221000000000_create_departments.exs b/priv/repo/migrations/20221000000000_create_departments.exs index 14a22628a..641873234 100644 --- a/priv/repo/migrations/20221000000000_create_departments.exs +++ b/priv/repo/migrations/20221000000000_create_departments.exs @@ -6,6 +6,9 @@ defmodule Atomic.Repo.Migrations.CreateDepartments do add :id, :binary_id, primary_key: true add :name, :string, null: false add :description, :text + add :banner, :string + add :collaborator_applications, :boolean, default: false, null: false + add :archived, :boolean, default: false, null: false add :organization_id, references(:organizations, on_delete: :delete_all, type: :binary_id), null: false diff --git a/priv/repo/migrations/20230880102641_create_collaborators.exs b/priv/repo/migrations/20230880102641_create_collaborators.exs index 2b0410d0b..e6446ccb5 100644 --- a/priv/repo/migrations/20230880102641_create_collaborators.exs +++ b/priv/repo/migrations/20230880102641_create_collaborators.exs @@ -5,10 +5,11 @@ defmodule Atomic.Repo.Migrations.CreateCollaborators do create table(:collaborators, primary_key: false) do add :id, :binary_id, primary_key: true add :accepted, :boolean, default: false + add :accepted_at, :naive_datetime add :user_id, references(:users, on_delete: :nothing, type: :binary_id), null: false - add :department_id, references(:departments, on_delete: :nothing, type: :binary_id), + add :department_id, references(:departments, on_delete: :delete_all, type: :binary_id), null: false timestamps() diff --git a/priv/repo/seeds/departments.exs b/priv/repo/seeds/departments.exs index faa11ee42..cbab532b6 100644 --- a/priv/repo/seeds/departments.exs +++ b/priv/repo/seeds/departments.exs @@ -6,23 +6,28 @@ defmodule Atomic.Repo.Seeds.Departments do alias Atomic.Organizations.{Department, Organization} alias Atomic.Repo - @department_names [ - "CAOS", - "Marketing e Conteúdo", - "Relações Externas e Parcerias", - "Pedagógico", - "Recreativo", - "Financeiro", - "Administrativo", - "Comunicação", - "Tecnologia", - "Design" + @departments [ + {"CAOS", + "O CAOS (Centro de Apoio ao Open-Source) é o departamento responsável por todo o desenvolvimento de software associado ao CeSIUM, quer sejam as plataformas que servem diretamente o núcleo e os seus eventos, ou as plataformas úteis para os alunos. Durante o processo, procura expandir o conhecimento dos seus colaboradores, para além daquilo que aprendem no curso. 💻"}, + {"Marketing e Conteúdo", + "O Departamento de Marketing e Conteúdo é responsável por promover todas as atividades efetuadas pelo núcleo, manter toda a identidade visual do CeSIUM e efetuar a comunicação através das nossas redes sociais. 💡"}, + {"Recreativo", + "O Departamento Recreativo é o responsável por organizar atividades de cariz cultural e/ou lúdico, com o intuito de promover a relação entre os vários estudantes, professores e funcionários. 🤪"}, + {"Pedagógico", + "O Departamento Pedagógico é o responsável por promover a relação entre a Direção de Curso e os alunos do LEI/MEI/MIEI e, ainda, por organizar atividades que complementam a formação académica dos mesmos. ✏️"}, + {"Relações Externas e Merch", + "O Departamento de Relações Externas e Merch é responsável por todo o merchandising do núcleo e por manter e fechar parceriais com diversas entidades e negócios, das quais os sócios do CeSIUM, assim como o núcleo, possam tirar proveito. 🤝"}, + {"Financeiro", + "O Departamento Financeiro é o responsável por toda a gestão financeira do núcleo, desde a organização de contas, ao controlo de despesas e receitas. 💰"}, + {"Administrativo", + "O Departamento Administrativo é o responsável por toda a gestão administrativa do núcleo, desde a organização de documentos, ao controlo de processos e procedimentos. 📝"} ] def run do case Repo.all(Department) do [] -> seed_departments() + seed_collaborators() _ -> Mix.shell().error("Found departments, aborting seeding departments.") @@ -33,12 +38,40 @@ defmodule Atomic.Repo.Seeds.Departments do organizations = Repo.all(Organization) for organization <- organizations do - for i <- 0..Enum.random(4..(length(@department_names) - 1)) do - %{ - name: Enum.at(@department_names, i), - organization_id: organization.id - } - |> Departments.create_department() + for i <- 0..Enum.random(4..(length(@departments) - 1)) do + case @departments |> Enum.at(i) do + {name, description} -> + %{ + name: name, + description: description, + organization_id: organization.id, + collaborator_applications: Enum.random([true, false]) + } + |> Departments.create_department() + end + end + end + end + + def seed_collaborators do + for department <- Repo.all(Department) do + for user <- Repo.all(Atomic.Accounts.User) do + is_accepted = Enum.random([true, false]) + + if Enum.random(0..6) == 1 do + %{ + department_id: department.id, + user_id: user.id, + accepted: is_accepted, + accepted_at: + if is_accepted do + NaiveDateTime.utc_now() + else + nil + end + } + |> Departments.create_collaborator() + end end end end diff --git a/storybook/components/gradient.story.exs b/storybook/components/gradient.story.exs new file mode 100644 index 000000000..df2e86958 --- /dev/null +++ b/storybook/components/gradient.story.exs @@ -0,0 +1,29 @@ +defmodule AtomicWeb.Storybook.Components.Gradient do + use PhoenixStorybook.Story, :component + + alias AtomicWeb.Components.Gradient + + def function, do: &Gradient.gradient/1 + + def template do + """ +
+ <.lsb-variation/> +
+ """ + end + + def variations do + [ + %Variation{ + id: :random + }, + %Variation{ + id: :predictable, + attributes: %{ + seed: "CAOS" + } + } + ] + end +end