localspot/lib/localspot_web/live/admin_live/import.ex
Kevin Sivic 94cb0870ff Add duplicate detection to business import
- Skip businesses that already exist (by slug)
- Track skipped count in import results
- Display skipped count in admin import UI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 08:14:42 -05:00

207 lines
7.1 KiB
Elixir

defmodule LocalspotWeb.AdminLive.Import do
use LocalspotWeb, :live_view
alias Localspot.Businesses.Import
@json_example """
{
"businesses": [
{
"name": "Business Name",
"category": "category-slug",
"street_address": "123 Main St",
"city": "Columbus",
"state": "OH",
"zip_code": "43215",
"hours": [{"day": 1, "opens_at": "09:00", "closes_at": "17:00"}],
"photos": [{"url": "https://...", "primary": true}]
}
]
}
"""
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Import Businesses")
|> assign(:uploaded_files, [])
|> assign(:import_result, nil)
|> assign(:importing, false)
|> assign(:json_example, @json_example)
|> allow_upload(:import_file,
accept: ~w(.json),
max_entries: 1,
max_file_size: 10_000_000
)}
end
@impl true
def handle_event("validate", _params, socket) do
{:noreply, socket}
end
@impl true
def handle_event("import", _params, socket) do
socket = assign(socket, :importing, true)
result =
consume_uploaded_entries(socket, :import_file, fn %{path: path}, _entry ->
{:ok, Import.from_file(path)}
end)
import_result =
case result do
[{:ok, result}] -> result
[{:error, reason}] -> {:error, reason}
[] -> {:error, :no_file}
end
{:noreply,
socket
|> assign(:import_result, import_result)
|> assign(:importing, false)}
end
@impl true
def handle_event("clear_result", _params, socket) do
{:noreply, assign(socket, :import_result, nil)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Import Businesses
<:subtitle>Upload a JSON file to bulk import businesses</:subtitle>
</.header>
<div class="mt-8 max-w-2xl">
<div class="alert alert-info mb-6">
<.icon name="hero-information-circle" class="w-5 h-5" />
<div>
<p>Upload a JSON file with the following structure:</p>
<pre class="mt-2 text-xs bg-base-300 p-2 rounded overflow-x-auto"><code>{@json_example}</code></pre>
</div>
</div>
<.form for={%{}} phx-change="validate" phx-submit="import" class="space-y-4">
<div
class="border-2 border-dashed border-base-300 rounded-lg p-8 text-center"
phx-drop-target={@uploads.import_file.ref}
>
<.live_file_input upload={@uploads.import_file} class="hidden" />
<div :if={@uploads.import_file.entries == []}>
<.icon name="hero-document-arrow-up" class="w-12 h-12 mx-auto text-base-content/40" />
<p class="mt-2 text-base-content/60">
Drag and drop a JSON file here, or
<label for={@uploads.import_file.ref} class="link link-primary cursor-pointer">
browse
</label>
</p>
<p class="text-sm text-base-content/40 mt-1">Max file size: 10MB</p>
</div>
<div
:for={entry <- @uploads.import_file.entries}
class="flex items-center justify-between"
>
<div class="flex items-center gap-2">
<.icon name="hero-document" class="w-6 h-6 text-primary" />
<span>{entry.client_name}</span>
<span class="text-sm text-base-content/60">
({format_bytes(entry.client_size)})
</span>
</div>
<button
type="button"
class="btn btn-ghost btn-sm"
phx-click="cancel-upload"
phx-value-ref={entry.ref}
>
<.icon name="hero-x-mark" class="w-4 h-4" />
</button>
</div>
<div :for={err <- upload_errors(@uploads.import_file)} class="text-error text-sm mt-2">
{error_to_string(err)}
</div>
</div>
<button
type="submit"
class="btn btn-primary w-full"
disabled={@uploads.import_file.entries == [] or @importing}
>
<span :if={@importing} class="loading loading-spinner loading-sm"></span>
<span :if={!@importing}>
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Import Businesses
</span>
<span :if={@importing}>Importing...</span>
</button>
</.form>
<div :if={@import_result} class="mt-6">
<div :if={match?(%{imported: _, skipped: _, errors: _}, @import_result)} class="space-y-4">
<div class="alert alert-success">
<.icon name="hero-check-circle" class="w-5 h-5" />
<span>Successfully imported {@import_result.imported} business(es)</span>
</div>
<div :if={@import_result.skipped > 0} class="alert alert-info">
<.icon name="hero-information-circle" class="w-5 h-5" />
<span>Skipped {@import_result.skipped} duplicate(s)</span>
</div>
<div :if={length(@import_result.errors) > 0} class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<div>
<p>{length(@import_result.errors)} error(s) occurred:</p>
<ul class="list-disc list-inside mt-2 text-sm">
<li :for={{:error, index, reason} <- @import_result.errors}>
Row {index}: {inspect(reason)}
</li>
</ul>
</div>
</div>
<div class="flex gap-2">
<.link navigate={~p"/businesses"} class="btn btn-primary">
View Businesses
</.link>
<button type="button" class="btn btn-ghost" phx-click="clear_result">
Import More
</button>
</div>
</div>
<div :if={match?({:error, _}, @import_result)} class="alert alert-error">
<.icon name="hero-x-circle" class="w-5 h-5" />
<span>Import failed: {format_error(@import_result)}</span>
</div>
</div>
</div>
</Layouts.app>
"""
end
defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B"
defp format_bytes(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1024, 1)} KB"
defp format_bytes(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB"
defp error_to_string(:too_large), do: "File is too large (max 10MB)"
defp error_to_string(:not_accepted), do: "Only JSON files are accepted"
defp error_to_string(:too_many_files), do: "Only one file at a time"
defp error_to_string(err), do: "Error: #{inspect(err)}"
defp format_error({:error, {:file_error, reason}}),
do: "Could not read file: #{inspect(reason)}"
defp format_error({:error, {:json_error, _}}), do: "Invalid JSON format"
defp format_error({:error, {:invalid_format, msg}}), do: msg
defp format_error({:error, :no_file}), do: "No file selected"
defp format_error({:error, reason}), do: inspect(reason)
end