<%= 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