diff --git a/lib/kith/contacts/activity_stream.ex b/lib/kith/contacts/activity_stream.ex new file mode 100644 index 0000000..e3f987b --- /dev/null +++ b/lib/kith/contacts/activity_stream.ex @@ -0,0 +1,239 @@ +defmodule Kith.Contacts.ActivityStream do + @moduledoc """ + Unified activity stream for a contact, merging all entry types + (notes, calls, life events, activities, tasks, gifts, conversations, photos) + into a single chronological timeline. + """ + + alias Kith.Activities + alias Kith.Contacts + alias Kith.Conversations + alias Kith.Gifts + alias Kith.Tasks + + @type entry_type :: + :note | :call | :life_event | :activity | :task | :gift | :conversation | :photo + + @all_types ~w(note call life_event activity task gift conversation photo)a + + @doc """ + Lists activity entries for a contact, merged into a single chronological stream. + + ## Options + + * `:types` - list of entry types to include (default: all types) + * `:limit` - max entries to return (default: 20) + * `:current_user_id` - required, used for privacy filtering on notes + + Returns a list of maps with normalized shape: + + %{ + id: integer, + type: atom, + title: string, + body: string | nil, + occurred_at: DateTime.t(), + record: struct # the original schema struct + } + """ + @spec list_activity(integer(), integer(), keyword()) :: [map()] + def list_activity(account_id, contact_id, opts \\ []) do + types = Keyword.get(opts, :types, @all_types) + limit = Keyword.get(opts, :limit, 20) + current_user_id = Keyword.fetch!(opts, :current_user_id) + + types + |> Enum.flat_map(&fetch_entries(&1, account_id, contact_id, current_user_id)) + |> Enum.sort_by(& &1.occurred_at, {:desc, DateTime}) + |> Enum.take(limit) + end + + @doc """ + Returns all supported entry types. + """ + def all_types, do: @all_types + + # --- Private fetchers per type --- + + defp fetch_entries(:note, _account_id, contact_id, current_user_id) do + contact_id + |> Contacts.list_notes(current_user_id) + |> Enum.map(&normalize_note/1) + end + + defp fetch_entries(:call, _account_id, contact_id, _current_user_id) do + contact_id + |> Activities.list_calls() + |> Enum.map(&normalize_call/1) + end + + defp fetch_entries(:life_event, _account_id, contact_id, _current_user_id) do + contact_id + |> Activities.list_life_events() + |> Enum.map(&normalize_life_event/1) + end + + defp fetch_entries(:activity, _account_id, contact_id, _current_user_id) do + contact_id + |> Activities.list_activities_for_contact() + |> Enum.map(&normalize_activity/1) + end + + defp fetch_entries(:task, account_id, contact_id, _current_user_id) do + account_id + |> Tasks.list_tasks(contact_id: contact_id) + |> Enum.map(&normalize_task/1) + end + + defp fetch_entries(:gift, account_id, contact_id, _current_user_id) do + account_id + |> Gifts.list_gifts(contact_id) + |> Enum.map(&normalize_gift/1) + end + + defp fetch_entries(:conversation, account_id, contact_id, _current_user_id) do + account_id + |> Conversations.list_conversations(contact_id) + |> Enum.map(&normalize_conversation/1) + end + + defp fetch_entries(:photo, _account_id, contact_id, _current_user_id) do + contact_id + |> Contacts.list_photos() + |> Enum.map(&normalize_photo/1) + end + + # --- Normalizers --- + + defp normalize_note(note) do + %{ + id: note.id, + type: :note, + title: truncate(strip_html(note.body), 80), + body: note.body, + occurred_at: note.inserted_at, + record: note + } + end + + defp normalize_call(call) do + direction = if call.call_direction, do: call.call_direction.name, else: nil + + title = + [direction, duration_text(call.duration_mins)] + |> Enum.reject(&is_nil/1) + |> Enum.join(" \u00b7 ") + |> then(fn + "" -> "Call" + text -> text + end) + + %{ + id: call.id, + type: :call, + title: title, + body: call.notes, + occurred_at: call.occurred_at, + record: call + } + end + + defp normalize_life_event(event) do + type_name = if event.life_event_type, do: event.life_event_type.name, else: nil + + %{ + id: event.id, + type: :life_event, + title: type_name || "Life event", + body: event.note, + occurred_at: date_to_datetime(event.occurred_on), + record: event + } + end + + defp normalize_activity(activity) do + %{ + id: activity.id, + type: :activity, + title: activity.title || "Activity", + body: activity.description, + occurred_at: activity.occurred_at, + record: activity + } + end + + defp normalize_task(task) do + %{ + id: task.id, + type: :task, + title: task.title, + body: task.description, + occurred_at: task.inserted_at, + record: task + } + end + + defp normalize_gift(gift) do + %{ + id: gift.id, + type: :gift, + title: gift.name || "Gift", + body: gift.description, + occurred_at: gift.inserted_at, + record: gift + } + end + + defp normalize_conversation(conversation) do + message_count = length(conversation.messages) + last_message = List.first(conversation.messages) + body_preview = if last_message, do: "#{message_count} messages", else: nil + + %{ + id: conversation.id, + type: :conversation, + title: conversation.subject || conversation.platform || "Conversation", + body: body_preview, + occurred_at: conversation.updated_at, + record: conversation + } + end + + defp normalize_photo(photo) do + %{ + id: photo.id, + type: :photo, + title: photo.file_name || "Photo", + body: nil, + occurred_at: photo.inserted_at, + record: photo + } + end + + # --- Helpers --- + + defp date_to_datetime(%Date{} = date) do + date |> DateTime.new!(~T[00:00:00], "Etc/UTC") + end + + defp date_to_datetime(nil), do: DateTime.from_unix!(0) + + defp duration_text(nil), do: nil + defp duration_text(mins) when mins < 60, do: "#{mins} min" + defp duration_text(mins), do: "#{div(mins, 60)}h #{rem(mins, 60)}m" + + defp strip_html(nil), do: "" + + defp strip_html(html) do + html + |> String.replace(~r/<[^>]+>/, " ") + |> String.replace(~r/\s+/, " ") + |> String.trim() + end + + defp truncate(text, max_length) when byte_size(text) <= max_length, do: text + + defp truncate(text, max_length) do + String.slice(text, 0, max_length) <> "..." + end +end diff --git a/lib/kith/contacts/phone_formatter.ex b/lib/kith/contacts/phone_formatter.ex index efdd7c4..1532732 100644 --- a/lib/kith/contacts/phone_formatter.ex +++ b/lib/kith/contacts/phone_formatter.ex @@ -30,9 +30,9 @@ defmodule Kith.Contacts.PhoneFormatter do has_plus -> {:ok, "+" <> digits} - # US/Canada: bare 10-digit number + # Bare 10-digit number — could be many countries, store as-is String.length(digits) == 10 -> - {:ok, "+1" <> digits} + {:ok, digits} # US/Canada: 11-digit starting with 1 String.length(digits) == 11 and String.starts_with?(digits, "1") -> diff --git a/lib/kith_web/components/ui.ex b/lib/kith_web/components/ui.ex index f9d7d75..f40ddec 100644 --- a/lib/kith_web/components/ui.ex +++ b/lib/kith_web/components/ui.ex @@ -1085,4 +1085,214 @@ defmodule KithWeb.UI do def translate_errors(errors, field) when is_list(errors) do for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) end + + # ========================================================================== + # Timeline Components + # ========================================================================== + + @type_colors %{ + note: %{dot: "bg-amber-600", badge_bg: "bg-amber-50", badge_text: "text-amber-800"}, + call: %{dot: "bg-blue-500", badge_bg: "bg-blue-50", badge_text: "text-blue-800"}, + life_event: %{ + dot: "bg-emerald-500", + badge_bg: "bg-emerald-50", + badge_text: "text-emerald-800" + }, + activity: %{dot: "bg-teal-500", badge_bg: "bg-teal-50", badge_text: "text-teal-800"}, + task: %{dot: "bg-violet-500", badge_bg: "bg-violet-50", badge_text: "text-violet-800"}, + gift: %{dot: "bg-rose-500", badge_bg: "bg-rose-50", badge_text: "text-rose-800"}, + photo: %{dot: "bg-indigo-500", badge_bg: "bg-indigo-50", badge_text: "text-indigo-800"}, + conversation: %{dot: "bg-pink-500", badge_bg: "bg-pink-50", badge_text: "text-pink-800"} + } + + @doc """ + Renders a colored dot for the activity timeline. + + ## Attributes + + * `:type` - atom, the entry type (e.g. `:note`, `:call`) + """ + attr :type, :atom, required: true + + def timeline_dot(assigns) do + colors = Map.get(@type_colors, assigns.type, %{dot: "bg-stone-400"}) + assigns = assign(assigns, :dot_class, colors.dot) + + ~H""" +
+ """ + end + + @doc """ + Renders a colored badge for an activity entry type. + + ## Attributes + + * `:type` - atom, the entry type (e.g. `:note`, `:call`) + """ + attr :type, :atom, required: true + + def type_badge(assigns) do + colors = + Map.get(@type_colors, assigns.type, %{ + badge_bg: "bg-stone-100", + badge_text: "text-stone-600" + }) + + label = type_label(assigns.type) + + assigns = + assign(assigns, badge_bg: colors.badge_bg, badge_text: colors.badge_text, label: label) + + ~H""" + + {@label} + + """ + end + + defp type_label(:note), do: "Note" + defp type_label(:call), do: "Call" + defp type_label(:life_event), do: "Life Event" + defp type_label(:activity), do: "Activity" + defp type_label(:task), do: "Task" + defp type_label(:gift), do: "Gift" + defp type_label(:photo), do: "Photo" + defp type_label(:conversation), do: "Conversation" + defp type_label(_), do: "Entry" + + @doc """ + Renders a multi-select filter dropdown for the activity stream. + + Uses Alpine.js for open/close state and selection management. + + ## Attributes + + * `:active_filters` - MapSet of active filter type atoms + * `:all_types` - list of all available type atoms + """ + attr :active_filters, :any, required: true + attr :all_types, :list, required: true + attr :target, :any, default: nil + + def filter_dropdown(assigns) do + assigns = assign(assigns, :type_colors, @type_colors) + + ~H""" +
+ + + <%!-- Dropdown panel --%> +
+
+ +
+
0} + class="border-t border-[var(--color-border-subtle)] p-1" + > + +
+
+
+ """ + end + + @doc """ + Renders removable filter chips for active filters. + + ## Attributes + + * `:active_filters` - MapSet of active filter type atoms + """ + attr :active_filters, :any, required: true + attr :target, :any, default: nil + + def filter_chips(assigns) do + ~H""" +
0} class="flex flex-wrap items-center gap-1.5"> + + <.type_badge type={type} /> + + + +
+ """ + end end diff --git a/lib/kith_web/live/contact_live/about_component.ex b/lib/kith_web/live/contact_live/about_component.ex new file mode 100644 index 0000000..267ce77 --- /dev/null +++ b/lib/kith_web/live/contact_live/about_component.ex @@ -0,0 +1,321 @@ +defmodule KithWeb.ContactLive.AboutComponent do + use KithWeb, :live_component + + alias Kith.Contacts + + @impl true + def mount(socket) do + {:ok, + socket + |> assign(:editing, false) + |> assign(:contact_search, "") + |> assign(:contact_results, []) + |> assign(:selected_contact_id, nil)} + end + + @impl true + def update(assigns, socket) do + {:ok, socket |> assign(assigns)} + end + + @impl true + def handle_event("edit", _params, socket) do + selected_id = socket.assigns.contact.first_met_through_id + + {:noreply, + socket + |> assign(:editing, true) + |> assign(:contact_search, "") + |> assign(:contact_results, []) + |> assign(:selected_contact_id, selected_id && to_string(selected_id))} + end + + def handle_event("cancel", _params, socket) do + {:noreply, + socket + |> assign(:editing, false) + |> assign(:contact_search, "") + |> assign(:contact_results, [])} + end + + def handle_event("search-contacts", %{"value" => query}, socket) do + results = + if String.length(query) >= 2 do + socket.assigns.account_id + |> Contacts.search_contacts(query) + |> Enum.reject(&(&1.id == socket.assigns.contact.id)) + |> Enum.take(8) + else + [] + end + + {:noreply, + socket + |> assign(:contact_search, query) + |> assign(:contact_results, results)} + end + + def handle_event("select-contact", %{"id" => id}, socket) do + {:noreply, + socket + |> assign(:selected_contact_id, id) + |> assign(:contact_search, "") + |> assign(:contact_results, [])} + end + + def handle_event("clear-contact", _params, socket) do + {:noreply, assign(socket, :selected_contact_id, nil)} + end + + def handle_event("save", %{"contact" => params}, socket) do + contact = socket.assigns.contact + + params = + Map.put(params, "first_met_through_id", socket.assigns.selected_contact_id || "") + + case Contacts.update_contact(contact, params) do + {:ok, updated} -> + updated = Kith.Repo.preload(updated, [:first_met_through], force: true) + send(self(), {:contact_updated, updated}) + + {:noreply, + socket + |> assign(:contact, updated) + |> assign(:editing, false) + |> put_flash(:info, "Updated.")} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Could not save changes.")} + end + end + + @impl true + def render(assigns) do + ~H""" +
+
+ About + +
+ + <%= if @editing do %> + <.form for={%{}} phx-submit="save" phx-target={@myself} class="mt-2 space-y-2.5"> +
+ + +
+ +
+ + How we met + +
+ +
+ + +
+ +
+ + +
+ +
+ + <%= if @selected_contact_id do %> + <.selected_contact_badge + contact={find_selected_contact(@contact, @selected_contact_id)} + myself={@myself} + /> + <% else %> + + <%= if @contact_results != [] do %> +
+ <%= for c <- @contact_results do %> + + <% end %> +
+ <% end %> + <% end %> +
+ +
+ + +
+ +
+ + +
+ + <% else %> +

+ {@contact.description} +

+

+ No description added. +

+
+ How we met + <%= if has_first_met_data?(@contact) do %> +
+
+
When
+
+ +
+
+
+
Where
+
{@contact.first_met_where}
+
+
+
Through
+
+ <.link + navigate={~p"/contacts/#{@contact.first_met_through.id}"} + class="text-[var(--color-accent)] hover:underline" + > + {@contact.first_met_through.display_name} + +
+
+
+

+ {@contact.first_met_additional_info} +

+ <% else %> +

Not recorded yet.

+ <% end %> +
+ <% end %> +
+ """ + end + + defp selected_contact_badge(assigns) do + ~H""" +
+ <%= if @contact do %> + + + {@contact.display_name} + + <% else %> + Unknown contact + <% end %> + +
+ """ + end + + defp find_selected_contact(contact, selected_id) do + id = if is_binary(selected_id), do: String.to_integer(selected_id), else: selected_id + + if contact.first_met_through && contact.first_met_through.id == id do + contact.first_met_through + else + try do + Contacts.get_contact!(contact.account_id, id) + rescue + Ecto.NoResultsError -> nil + end + end + end + + defp has_first_met_data?(contact) do + contact.first_met_at != nil or + contact.first_met_where not in [nil, ""] or + contact.first_met_through_id != nil or + contact.first_met_additional_info not in [nil, ""] + end +end diff --git a/lib/kith_web/live/contact_live/activity_stream_component.ex b/lib/kith_web/live/contact_live/activity_stream_component.ex new file mode 100644 index 0000000..8a786f9 --- /dev/null +++ b/lib/kith_web/live/contact_live/activity_stream_component.ex @@ -0,0 +1,1022 @@ +defmodule KithWeb.ContactLive.ActivityStreamComponent do + @moduledoc """ + Unified activity timeline component that merges all entry types + (notes, calls, life events, activities, tasks, gifts, conversations, photos) + into a single chronological stream with multi-select filtering. + """ + use KithWeb, :live_component + + alias Kith.Activities + alias Kith.Contacts + alias Kith.Contacts.ActivityStream + alias Kith.Conversations + alias Kith.Gifts + alias Kith.Storage + alias Kith.Tasks + + @impl true + def mount(socket) do + {:ok, + socket + |> assign(:entries, []) + |> assign(:active_filters, MapSet.new()) + |> assign(:all_types, ActivityStream.all_types()) + |> assign(:modal_type, nil) + |> assign(:modal_entry_id, nil) + |> assign(:modal_error, nil) + |> allow_upload(:photo, + accept: ~w(.jpg .jpeg .png .gif .webp), + max_entries: 5, + max_file_size: 10_000_000 + )} + end + + @impl true + def update(assigns, socket) do + socket = assign(socket, assigns) + {:ok, load_entries(socket)} + end + + # --- Filter events --- + + @impl true + def handle_event("filter-toggle", %{"type" => type_str}, socket) do + type = String.to_existing_atom(type_str) + filters = socket.assigns.active_filters + + filters = + if MapSet.member?(filters, type), + do: MapSet.delete(filters, type), + else: MapSet.put(filters, type) + + {:noreply, socket |> assign(:active_filters, filters) |> load_entries()} + end + + def handle_event("filter-clear", _params, socket) do + {:noreply, socket |> assign(:active_filters, MapSet.new()) |> load_entries()} + end + + # --- Modal events --- + + def handle_event("open-entry-modal", %{"type" => type_str}, socket) do + type = String.to_existing_atom(type_str) + + {:noreply, + socket + |> assign(:modal_type, type) + |> assign(:modal_entry_id, nil) + |> assign(:modal_error, nil)} + end + + def handle_event("edit-entry", %{"type" => type_str, "id" => id_str}, socket) do + type = String.to_existing_atom(type_str) + id = String.to_integer(id_str) + + {:noreply, + socket + |> assign(:modal_type, type) + |> assign(:modal_entry_id, id) + |> assign(:modal_error, nil)} + end + + def handle_event("close-modal", _params, socket) do + {:noreply, + socket + |> assign(:modal_type, nil) + |> assign(:modal_entry_id, nil) + |> assign(:modal_error, nil)} + end + + def handle_event("save-entry", params, socket) do + %{modal_type: type, modal_entry_id: entry_id} = socket.assigns + + result = + if entry_id, + do: update_entry(type, entry_id, params, socket.assigns), + else: create_entry(type, params, socket.assigns) + + case result do + {:ok, _} -> + {:noreply, + socket + |> assign(:modal_type, nil) + |> assign(:modal_entry_id, nil) + |> assign(:modal_error, nil) + |> load_entries()} + + {:error, message} -> + {:noreply, assign(socket, :modal_error, message)} + end + end + + def handle_event("delete-entry", %{"type" => type_str, "id" => id_str}, socket) do + type = String.to_existing_atom(type_str) + id = String.to_integer(id_str) + + case delete_entry(type, id, socket.assigns) do + {:ok, _} -> + {:noreply, + socket + |> assign(:modal_type, nil) + |> assign(:modal_entry_id, nil) + |> load_entries()} + + {:error, _} -> + {:noreply, socket} + end + end + + # --- Photo upload events --- + + def handle_event("validate-photo", _params, socket) do + {:noreply, socket} + end + + def handle_event("upload-photo", _params, socket) do + contact_id = socket.assigns.contact_id + + uploaded_photos = + consume_uploaded_entries(socket, :photo, fn %{path: path}, entry -> + key = "contacts/#{contact_id}/photos/#{entry.uuid}-#{entry.client_name}" + {:ok, _} = Storage.upload(path, key) + + {:ok, photo} = + Contacts.create_photo( + Contacts.get_contact!(socket.assigns.account_id, contact_id), + %{ + file_name: entry.client_name, + storage_key: key, + file_size: entry.client_size, + content_type: entry.client_type + } + ) + + {:ok, photo} + end) + + if uploaded_photos != [] do + {:noreply, + socket + |> assign(:modal_type, nil) + |> load_entries() + |> put_flash(:info, "#{length(uploaded_photos)} photo(s) uploaded.")} + else + {:noreply, socket} + end + end + + def handle_event("cancel-photo-upload", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :photo, ref)} + end + + # --- CRUD helpers --- + + defp create_entry(:note, %{"entry" => params}, assigns) do + contact = Contacts.get_contact!(assigns.account_id, assigns.contact_id) + + Contacts.create_note(contact, assigns.current_user_id, params) + |> normalize_result() + end + + defp create_entry(:call, %{"entry" => params}, assigns) do + contact = Contacts.get_contact!(assigns.account_id, assigns.contact_id) + Activities.create_call(contact, params) |> normalize_result() + end + + defp create_entry(:life_event, %{"entry" => params}, assigns) do + contact = Contacts.get_contact!(assigns.account_id, assigns.contact_id) + Activities.create_life_event(contact, params) |> normalize_result() + end + + defp create_entry(:task, %{"entry" => params}, assigns) do + params = Map.put(params, "contact_id", assigns.contact_id) + Tasks.create_task(assigns.account_id, assigns.current_user_id, params) |> normalize_result() + end + + defp create_entry(:gift, %{"entry" => params}, assigns) do + params = Map.put(params, "contact_id", assigns.contact_id) + Gifts.create_gift(assigns.account_id, assigns.current_user_id, params) |> normalize_result() + end + + defp create_entry(:conversation, %{"entry" => params}, assigns) do + params = Map.put(params, "contact_id", assigns.contact_id) + + Conversations.create_conversation(assigns.account_id, assigns.current_user_id, params) + |> normalize_result() + end + + defp create_entry(:activity, %{"entry" => params}, assigns) do + Activities.create_activity(assigns.account_id, params, [assigns.contact_id]) + |> normalize_result() + end + + defp create_entry(_, _, _), do: {:error, "Unsupported type"} + + defp update_entry(:note, id, %{"entry" => params}, assigns) do + note = Contacts.get_note!(assigns.account_id, id, assigns.current_user_id) + Contacts.update_note(note, params) |> normalize_result() + end + + defp update_entry(:call, id, %{"entry" => params}, assigns) do + call = Activities.get_call!(assigns.account_id, id) + Activities.update_call(call, params) |> normalize_result() + end + + defp update_entry(:task, id, %{"entry" => params}, assigns) do + task = Tasks.get_task!(assigns.account_id, id) + Tasks.update_task(task, params) |> normalize_result() + end + + defp update_entry(:gift, id, %{"entry" => params}, assigns) do + gift = Gifts.get_gift!(assigns.account_id, id) + Gifts.update_gift(gift, params) |> normalize_result() + end + + defp update_entry(_, _, _, _), do: {:error, "Update not supported for this type"} + + defp delete_entry(:note, id, assigns) do + note = Contacts.get_note!(assigns.account_id, id, assigns.current_user_id) + Contacts.delete_note(note) |> normalize_result() + end + + defp delete_entry(:call, id, assigns) do + call = Activities.get_call!(assigns.account_id, id) + Activities.delete_call(call) |> normalize_result() + end + + defp delete_entry(:task, id, assigns) do + task = Tasks.get_task!(assigns.account_id, id) + Tasks.delete_task(task) |> normalize_result() + end + + defp delete_entry(:gift, id, assigns) do + gift = Gifts.get_gift!(assigns.account_id, id) + Gifts.delete_gift(gift) |> normalize_result() + end + + defp delete_entry(_, _, _), do: {:error, "Delete not supported for this type"} + + defp normalize_result({:ok, result}), do: {:ok, result} + defp normalize_result({:error, %Ecto.Changeset{} = cs}), do: {:error, changeset_error(cs)} + defp normalize_result({:error, _, %Ecto.Changeset{} = cs, _}), do: {:error, changeset_error(cs)} + defp normalize_result({:error, msg}) when is_binary(msg), do: {:error, msg} + defp normalize_result({:error, _}), do: {:error, "Something went wrong"} + + defp changeset_error(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Regex.replace(~r"%{(\w+)}", msg, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + |> Enum.map_join(", ", fn {field, errors} -> "#{field}: #{Enum.join(errors, ", ")}" end) + end + + # --- Data loading --- + + defp load_entries(socket) do + %{account_id: account_id, contact_id: contact_id, current_user_id: current_user_id} = + socket.assigns + + filters = socket.assigns.active_filters + opts = [current_user_id: current_user_id, limit: 50] + + opts = + if MapSet.size(filters) > 0, + do: Keyword.put(opts, :types, MapSet.to_list(filters)), + else: opts + + entries = ActivityStream.list_activity(account_id, contact_id, opts) + assign(socket, :entries, entries) + end + + defp photos_only?(filters) do + MapSet.size(filters) == 1 and MapSet.member?(filters, :photo) + end + + # --- Render --- + + @impl true + def render(assigns) do + ~H""" +
+ <%!-- Stream Header --%> +
+
+ Activity +
+ <.filter_dropdown + active_filters={@active_filters} + all_types={@all_types} + target={@myself} + /> + + + + <%!-- More types overflow --%> +
+ +
+ +
+
+
+
+
0} class="mt-2.5"> + <.filter_chips active_filters={@active_filters} target={@myself} /> +
+
+ + <%!-- Entry Modal --%> + <.modal + :if={@modal_type} + id="entry-modal" + show + on_cancel={JS.push("close-modal", target: @myself)} + > + <.entry_modal_content + type={@modal_type} + entry_id={@modal_entry_id} + error={@modal_error} + myself={@myself} + account_id={@account_id} + contact_id={@contact_id} + uploads={@uploads} + /> + + + <%!-- Photos Gallery Mode --%> +
0} + class="grid grid-cols-3 sm:grid-cols-4 gap-2" + > +
+
+ <.icon name="hero-photo" class="size-6" /> +
+
+
+ + <%!-- Timeline Mode --%> +
+
+ +
+ <.icon name="hero-clock" class="size-8 text-[var(--color-text-disabled)] mx-auto mb-2" /> +

No activity yet

+

+ Add a note, log a call, or record an event to get started. +

+
+ +
+ <.timeline_dot type={entry.type} /> + +
+
+ + <.relative_time_or_date datetime={entry.occurred_at} /> + + <.type_badge type={entry.type} /> + + {duration_text(entry.record.duration_mins)} + + + {entry.record.platform} + +
+ <%!-- Entry overflow menu --%> +
+ +
+ + +
+
+
+ +
+ + {entry.title} + + + {entry.title} + +
+ +
+ {if entry.type == :note, do: strip_html(entry.body), else: entry.body} +
+ +
+
+ <.icon name="hero-photo" class="size-5" /> +
+
+
+
+
+ """ + end + + # --- Modal form content per type --- + + defp entry_modal_content(%{type: :note} = assigns) do + ~H""" +
+

{if @entry_id, do: "Edit Note", else: "New Note"}

+ <.entry_error error={@error} /> + <.form for={%{}} phx-submit="save-entry" phx-target={@myself}> +
+
+ +