diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index b633e6946..ac01fd99e 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -134,6 +134,8 @@ defmodule Algora.Accounts do id: u.id, handle: u.handle, name: u.name, + provider_login: u.provider_login, + provider_meta: u.provider_meta, avatar_url: u.avatar_url, bio: u.bio, country: u.country, diff --git a/lib/algora/contracts/contracts.ex b/lib/algora/contracts/contracts.ex index c1232ca62..fc5554607 100644 --- a/lib/algora/contracts/contracts.ex +++ b/lib/algora/contracts/contracts.ex @@ -26,11 +26,12 @@ defmodule Algora.Contracts do @type criterion :: {:id, binary()} | {:client_id, binary()} + | {:contractor_id, binary()} | {:original_contract_id, binary()} | {:open?, true} | {:active_or_paid?, true} | {:original?, true} - | {:status, :open | :paid} + | {:status, :draft | :active | :paid} | {:after, non_neg_integer()} | {:before, non_neg_integer()} | {:order, :asc | :desc} @@ -665,6 +666,9 @@ defmodule Algora.Contracts do {:id, id}, query -> from([c] in query, where: c.id == ^id) + {:contractor_id, contractor_id}, query -> + from([c] in query, where: c.contractor_id == ^contractor_id) + {:client_id, client_id}, query -> from([c] in query, where: c.client_id == ^client_id) diff --git a/lib/algora/contracts/schemas/contract.ex b/lib/algora/contracts/schemas/contract.ex index 5b12774f2..1785bf5d0 100644 --- a/lib/algora/contracts/schemas/contract.ex +++ b/lib/algora/contracts/schemas/contract.ex @@ -6,6 +6,7 @@ defmodule Algora.Contracts.Contract do alias Algora.Activities.Activity alias Algora.Contracts.Contract alias Algora.MoneyUtils + alias Algora.Validations typed_schema "contracts" do field :status, Ecto.Enum, values: [:draft, :active, :paid, :cancelled, :disputed] @@ -78,6 +79,8 @@ defmodule Algora.Contracts.Contract do :status, :sequence_number, :hourly_rate, + :hourly_rate_min, + :hourly_rate_max, :hours_per_week, :start_date, :end_date, @@ -85,44 +88,19 @@ defmodule Algora.Contracts.Contract do :client_id, :contractor_id ]) - |> validate_required([ - :status, - :hourly_rate, - :hours_per_week, - :start_date, - :client_id, - :contractor_id - ]) + |> validate_required([:status, :hours_per_week, :client_id]) |> validate_number(:hours_per_week, greater_than: 0) - |> validate_number(:hourly_rate, greater_than: 0) + |> Validations.validate_money_positive(:hourly_rate) |> foreign_key_constraint(:client_id) |> foreign_key_constraint(:contractor_id) |> generate_id() + |> put_original_contract_id() end - def draft_changeset(contract, attrs) do - contract - |> cast(attrs, [ - :status, - :sequence_number, - :hourly_rate_min, - :hourly_rate_max, - :hours_per_week, - :start_date, - :end_date, - :original_contract_id, - :client_id - ]) - |> validate_required([ - :status, - :hourly_rate_min, - :hourly_rate_max, - :hours_per_week, - :start_date, - :client_id - ]) - |> validate_number(:hours_per_week, greater_than: 0) - |> foreign_key_constraint(:client_id) - |> generate_id() + def put_original_contract_id(changeset) do + case get_field(changeset, :original_contract_id) do + nil -> put_change(changeset, :original_contract_id, get_field(changeset, :id)) + _existing -> changeset + end end end diff --git a/lib/algora/organizations/organizations.ex b/lib/algora/organizations/organizations.ex index 3caeb824d..130d1eb29 100644 --- a/lib/algora/organizations/organizations.ex +++ b/lib/algora/organizations/organizations.ex @@ -43,7 +43,7 @@ defmodule Algora.Organizations do ) contract_changeset = - Contract.draft_changeset( + Contract.changeset( %Contract{}, Map.put(params.contract, :client_id, org.id) ) diff --git a/lib/algora_web/live/contract/modals/dispute_drawer.ex b/lib/algora_web/live/contract/modals/dispute_drawer.ex index dc59a090a..e13d94d1c 100644 --- a/lib/algora_web/live/contract/modals/dispute_drawer.ex +++ b/lib/algora_web/live/contract/modals/dispute_drawer.ex @@ -64,7 +64,7 @@ defmodule AlgoraWeb.Contract.Modals.DisputeDrawer do {Money.to_string!(@contract.amount_debited)} -
+
Contract Period
{Calendar.strftime(@contract.start_date, "%b %d")} - {Calendar.strftime( diff --git a/lib/algora_web/live/contract/view_live.ex b/lib/algora_web/live/contract/view_live.ex index 0356d17c4..1c8ec2624 100644 --- a/lib/algora_web/live/contract/view_live.ex +++ b/lib/algora_web/live/contract/view_live.ex @@ -38,14 +38,25 @@ defmodule AlgoraWeb.Contract.ViewLive do

Contract with {@contract.contractor.name}

-

- Started {Calendar.strftime(@contract.start_date, "%b %d, %Y")} -

+ <%= if @contract.start_date do %> +

+ Started {Calendar.strftime(@contract.start_date, "%b %d, %Y")} +

+ <% else %> +

+ Drafted on {Calendar.strftime(@contract.inserted_at, "%b %d, %Y")} +

+ <% end %>
-
- <.badge variant="success">Active -
+ <%= case @contract.status do %> + <% :draft -> %> + <.badge variant="warning">Draft + <% :active -> %> + <.badge variant="success">Active + <% _ -> %> + <.badge variant="destructive">Inactive + <% end %>
@@ -84,14 +95,14 @@ defmodule AlgoraWeb.Contract.ViewLive do
- <.tabs :let={builder} id="contract-tabs" default="payments" class="mt-8"> + <.tabs :let={builder} id="contract-tabs" default="details" class="mt-8"> <.tabs_list class="flex w-full space-x-1 rounded-lg bg-muted p-1"> - <.tabs_trigger builder={builder} value="payments" class="flex-1"> - <.icon name="tabler-credit-card" class="mr-2 h-4 w-4" /> Payments - <.tabs_trigger builder={builder} value="details" class="flex-1"> <.icon name="tabler-file-text" class="mr-2 h-4 w-4" /> Contract Details + <.tabs_trigger builder={builder} value="payments" class="flex-1"> + <.icon name="tabler-credit-card" class="mr-2 h-4 w-4" /> Payments + <.tabs_trigger builder={builder} value="activity" class="flex-1"> <.icon name="tabler-history" class="mr-2 h-4 w-4" /> Activity @@ -106,7 +117,7 @@ defmodule AlgoraWeb.Contract.ViewLive do <.card_content> -
+
<%= for contract <- @contract_chain do %> <%= case Contracts.get_payment_status(contract) do %> <% {:pending_timesheet, contract} -> %> @@ -119,7 +130,7 @@ defmodule AlgoraWeb.Contract.ViewLive do
Waiting for timesheet submission
-
+
{Calendar.strftime(contract.start_date, "%b %d")} - {Calendar.strftime( contract.end_date, "%b %d, %Y" @@ -142,7 +153,7 @@ defmodule AlgoraWeb.Contract.ViewLive do
Ready to release payment for {contract.timesheet.hours_worked} hours
-
+
{Calendar.strftime(contract.start_date, "%b %d")} - {Calendar.strftime( contract.end_date, "%b %d, %Y" @@ -464,19 +475,30 @@ defmodule AlgoraWeb.Contract.ViewLive do thread = Chat.get_or_create_thread!(contract) messages = thread.id |> Chat.list_messages() |> Repo.preload(:sender) - {:ok, - socket - |> assign(:contract, contract) - |> assign(:contract_chain, contract_chain) - |> assign(:has_more, length(contract_chain) >= page_size()) - |> assign(:page_title, "Contract with #{contract.contractor.name}") - |> assign(:messages, messages) - |> assign(:thread, thread) - |> assign(:show_release_renew_modal, false) - |> assign(:show_release_modal, false) - |> assign(:show_dispute_modal, false) - |> assign(:fee_data, Contracts.calculate_fee_data(contract)) - |> assign(:org_members, Organizations.list_org_members(contract.client))} + case socket.assigns[:current_user] do + nil -> + {:ok, redirect(socket, to: ~p"/auth/login?return_to=#{~p"/org/#{contract.client.handle}/contracts/#{id}"}")} + + current_user -> + if current_user.id != contract.contractor_id and + not (socket.assigns.all_contexts |> Enum.map(& &1.id) |> Enum.member?(contract.client_id)) do + {:ok, raise(AlgoraWeb.NotFoundError)} + else + {:ok, + socket + |> assign(:contract, contract) + |> assign(:contract_chain, contract_chain) + |> assign(:has_more, length(contract_chain) >= page_size()) + |> assign(:page_title, "Contract with #{contract.contractor.name}") + |> assign(:messages, messages) + |> assign(:thread, thread) + |> assign(:show_release_renew_modal, false) + |> assign(:show_release_modal, false) + |> assign(:show_dispute_modal, false) + |> assign(:fee_data, Contracts.calculate_fee_data(contract)) + |> assign(:org_members, Organizations.list_org_members(contract.client))} + end + end end def handle_event("send_message", %{"message" => content}, socket) do diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 879ed2565..82d07ded3 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -6,6 +6,7 @@ defmodule AlgoraWeb.Org.DashboardLive do import Ecto.Changeset alias Algora.Accounts + alias Algora.Accounts.User alias Algora.Bounties alias Algora.Contracts alias Algora.Github @@ -62,11 +63,33 @@ defmodule AlgoraWeb.Org.DashboardLive do end end + defmodule ContractForm do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + embedded_schema do + field :hourly_rate, USD + field :hours_per_week, :integer + end + + def changeset(form, attrs) do + form + |> cast(attrs, [:hourly_rate, :hours_per_week]) + |> validate_required([:hourly_rate, :hours_per_week]) + |> Validations.validate_money_positive(:hourly_rate) + |> validate_number(:hours_per_week, greater_than: 0, less_than_or_equal_to: 40) + end + end + @impl true def mount(_params, _session, socket) do %{current_org: current_org} = socket.assigns if socket.assigns.current_user_role in [:admin, :mod] do + top_earners = Accounts.list_developers(org_id: current_org.id, earnings_gt: Money.zero(:USD)) + installations = Workspace.list_installations_by(connected_user_id: current_org.id, provider: "github") if connected?(socket) do @@ -77,9 +100,14 @@ defmodule AlgoraWeb.Org.DashboardLive do socket |> assign(:has_fresh_token?, Accounts.has_fresh_token?(socket.assigns.current_user)) |> assign(:installations, installations) + |> assign(:matching_devs, top_earners) |> assign(:oauth_url, Github.authorize_url(%{socket_id: socket.id})) |> assign(:bounty_form, to_form(BountyForm.changeset(%BountyForm{}, %{}))) |> assign(:tip_form, to_form(TipForm.changeset(%TipForm{}, %{}))) + |> assign(:contract_form, to_form(ContractForm.changeset(%ContractForm{}, %{}))) + |> assign(:show_contract_modal, false) + |> assign(:selected_developer, nil) + |> assign_contracts() |> assign_achievements()} else {:ok, redirect(socket, to: ~p"/org/#{current_org.handle}/home")} @@ -137,12 +165,232 @@ defmodule AlgoraWeb.Org.DashboardLive do {create_tip(assigns)}
+ +
+
+

+ Contracts +

+

+ Engage top-performing developers with contract opportunities +

+
+
+ + + <%= for user <- @matching_devs do %> + <.matching_dev user={user} contracts={@contracts} current_org={@current_org} /> + <% end %> + +
+
+
{sidebar(assigns)} + <.drawer show={@show_contract_modal} direction="right" on_cancel="close_contract_drawer"> + <.drawer_header :if={@selected_developer}> + <.drawer_title>Offer Contract + <.drawer_description> + Once you send an offer, {@selected_developer.name} will be notified and can accept or decline. + + + <.drawer_content :if={@selected_developer} class="mt-4"> + <.form for={@contract_form} phx-change="validate_contract" phx-submit="create_contract"> +
+ <.card> + <.card_header> + <.card_title>Developer + + <.card_content> +
+ <.avatar class="h-20 w-20 rounded-full"> + <.avatar_image + src={@selected_developer.avatar_url} + alt={@selected_developer.name} + /> + <.avatar_fallback class="rounded-lg"> + + +
+
+ {@selected_developer.name} +
+ +
+ <.link + :if={@selected_developer.provider_login} + href={"https://github.com/#{@selected_developer.provider_login}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + + {@selected_developer.provider_login} + + <.link + :if={@selected_developer.provider_meta["twitter_handle"]} + href={"https://x.com/#{@selected_developer.provider_meta["twitter_handle"]}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="tabler-brand-x" class="h-4 w-4" /> + + {@selected_developer.provider_meta["twitter_handle"]} + + +
+ <.icon name="tabler-map-pin" class="h-4 w-4" /> + + {@selected_developer.provider_meta["location"]} + +
+
+ <.icon name="tabler-building" class="h-4 w-4" /> + + {@selected_developer.provider_meta["company"] |> String.trim_leading("@")} + +
+
+ +
+ <%= for tech <- @selected_developer.tech_stack do %> +
+ {tech} +
+ <% end %> +
+
+
+ + + + <.card> + <.card_header> + <.card_title>Contract Details + + <.card_content> +
+ <.input + label="Hourly Rate" + icon="tabler-currency-dollar" + field={@contract_form[:hourly_rate]} + /> + <.input label="Hours per Week" field={@contract_form[:hours_per_week]} /> +
+ + + +
+ <.button variant="secondary" phx-click="close_contract_drawer" type="button"> + Cancel + + <.button type="submit"> + Send Contract Offer <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
+
+ + + + """ + end + + defp matching_dev(assigns) do + ~H""" + + +
+
+ <.link navigate={User.url(@user)}> + <.avatar class="h-20 w-20 rounded-full"> + <.avatar_image src={@user.avatar_url} alt={@user.name} /> + <.avatar_fallback class="rounded-lg"> + + + +
+
+ <.link navigate={User.url(@user)} class="font-semibold hover:underline"> + {@user.name} + +
+ +
+ <.link + :if={@user.provider_login} + href={"https://github.com/#{@user.provider_login}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + + {@user.provider_login} + + <.link + :if={@user.provider_meta["twitter_handle"]} + href={"https://x.com/#{@user.provider_meta["twitter_handle"]}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="tabler-brand-x" class="h-4 w-4" /> + {@user.provider_meta["twitter_handle"]} + +
+ <.icon name="tabler-map-pin" class="h-4 w-4" /> + {@user.provider_meta["location"]} +
+
+ <.icon name="tabler-building" class="h-4 w-4" /> + + {@user.provider_meta["company"] |> String.trim_leading("@")} + +
+
+ +
+ <%= for tech <- @user.tech_stack do %> +
+ {tech} +
+ <% end %> +
+
+
+ <%= if contract_for_user(@contracts, @user) do %> + <.button + variant="secondary" + navigate={ + ~p"/org/#{@current_org.handle}/contracts/#{contract_for_user(@contracts, @user).id}" + } + > + View contract + + <% else %> + <.button phx-click="offer_contract" phx-value-user_id={@user.id}> + Offer contract + + <% end %> +
+ + """ end + defp contract_for_user(contracts, user) do + Enum.find(contracts, fn contract -> contract.contractor_id == user.id end) + end + defp create_bounty(assigns) do ~H""" <.card> @@ -329,6 +577,70 @@ defmodule AlgoraWeb.Org.DashboardLive do end end + def handle_event("offer_contract", %{"user_id" => user_id}, socket) do + developer = Enum.find(socket.assigns.matching_devs, &(&1.id == user_id)) + + {:noreply, + socket + |> assign(:selected_developer, developer) + |> assign(:show_contract_modal, true)} + end + + def handle_event("offer_contract", _params, socket) do + # When no user_id is provided, use the user from the current row + {:noreply, put_flash(socket, :error, "Please select a developer first")} + end + + def handle_event("close_contract_drawer", _params, socket) do + {:noreply, assign(socket, :show_contract_modal, false)} + end + + def handle_event("validate_contract", %{"contract_form" => params}, socket) do + changeset = + %ContractForm{} + |> ContractForm.changeset(params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :contract_form, to_form(changeset))} + end + + def handle_event("create_contract", %{"contract_form" => params}, socket) do + changeset = ContractForm.changeset(%ContractForm{}, params) + + case apply_action(changeset, :save) do + {:ok, data} -> + contract_params = %{ + client_id: socket.assigns.current_org.id, + contractor_id: socket.assigns.selected_developer.id, + hourly_rate: data.hourly_rate, + hours_per_week: data.hours_per_week, + status: :draft + } + + case Contracts.create_contract(contract_params) do + {:ok, _contract} -> + # TODO: send email + {:noreply, + socket + |> assign(:show_contract_modal, false) + |> assign_contracts() + |> put_flash(:info, "Contract offer sent to #{socket.assigns.selected_developer.name}")} + + {:error, changeset} -> + {:noreply, put_flash(socket, :error, "Failed to create contract: #{inspect(changeset.errors)}")} + end + + {:error, changeset} -> + {:noreply, assign(socket, :contract_form, to_form(changeset))} + end + end + + defp assign_contracts(socket) do + contracts = Contracts.list_contracts(client_id: socket.assigns.current_org.id, status: :draft) + + assign(socket, :contracts, contracts) + end + defp assign_achievements(socket) do status_fns = [ {&personalize_status/1, "Personalize Algora"}, diff --git a/lib/algora_web/live/user/dashboard_live.ex b/lib/algora_web/live/user/dashboard_live.ex index edde1a8a7..2c9bb29d4 100644 --- a/lib/algora_web/live/user/dashboard_live.ex +++ b/lib/algora_web/live/user/dashboard_live.ex @@ -14,13 +14,15 @@ defmodule AlgoraWeb.User.DashboardLive do Bounties.subscribe() end + contracts = Algora.Contracts.list_contracts(status: :draft, contractor_id: socket.assigns.current_user.id) + socket = socket |> assign(:view_mode, "compact") |> assign(:available_to_work, true) |> assign(:hourly_rate, Money.new!(50, :USD)) |> assign(:hours_per_week, 40) - |> assign(:contracts, Algora.Contracts.list_contracts(open?: true, limit: 10)) + |> assign(:contracts, contracts) |> assign(:achievements, fetch_achievements()) |> assign_tickets() @@ -33,7 +35,7 @@ defmodule AlgoraWeb.User.DashboardLive do <%= if length(@contracts) > 0 do %>
-
+

Hourly contracts @@ -41,7 +43,7 @@ defmodule AlgoraWeb.User.DashboardLive do

Paid out weekly

-
+
@@ -206,8 +208,7 @@ defmodule AlgoraWeb.User.DashboardLive do {:noreply, assign(socket, :view_mode, mode)} end - def handle_event("accept_contract", %{"org" => _org_handle}, socket) do - # TODO: Implement contract acceptance logic + def handle_event("view_contract", %{"org" => _org_handle}, socket) do {:noreply, socket} end @@ -328,10 +329,7 @@ defmodule AlgoraWeb.User.DashboardLive do {@contract.client.bio}
-
+
{Money.to_string!(@contract.hourly_rate)}/hr
@@ -352,20 +350,17 @@ defmodule AlgoraWeb.User.DashboardLive do
Total contract value
-
+
{Money.to_string!(Money.mult!(@contract.hourly_rate, @contract.hours_per_week))} / wk
<.button navigate={~p"/org/#{@contract.client.handle}/contracts/#{@contract.id}"} - phx-click="accept_contract" + phx-click="view_contract" phx-value-org={@contract.client.handle} size="sm" > - Accept contract + View contract
diff --git a/priv/repo/migrations/20250304232627_make_contract_dates_nullable.exs b/priv/repo/migrations/20250304232627_make_contract_dates_nullable.exs new file mode 100644 index 000000000..f8f8e9157 --- /dev/null +++ b/priv/repo/migrations/20250304232627_make_contract_dates_nullable.exs @@ -0,0 +1,9 @@ +defmodule Algora.Repo.Migrations.MakeContractDatesNullable do + use Ecto.Migration + + def change do + alter table(:contracts) do + modify :start_date, :utc_datetime_usec, null: true + end + end +end