refactor: migrate templates and static files to self-contained modules
Templates Migration: - Migrate admin templates to modules (tenancy, billing, monitoring, marketplace, etc.) - Migrate vendor templates to modules (tenancy, billing, orders, messaging, etc.) - Migrate storefront templates to modules (catalog, customers, orders, cart, checkout, cms) - Migrate public templates to modules (billing, marketplace, cms) - Keep shared templates in app/templates/ (base.html, errors/, partials/, macros/) - Migrate letzshop partials to marketplace module Static Files Migration: - Migrate admin JS to modules: tenancy (23 files), core (5 files), monitoring (1 file) - Migrate vendor JS to modules: tenancy (4 files), core (2 files) - Migrate shared JS: vendor-selector.js to core, media-picker.js to cms - Migrate storefront JS: storefront-layout.js to core - Keep framework JS in static/ (api-client, utils, money, icons, log-config, lib/) - Update all template references to use module_static paths Naming Consistency: - Rename static/platform/ to static/public/ - Rename app/templates/platform/ to app/templates/public/ - Update all extends and static references Documentation: - Update module-system.md with shared templates documentation - Update frontend-structure.md with new module JS organization Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
{# app/templates/admin/background-tasks.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
|
||||
{% block title %}Background Tasks{% endblock %}
|
||||
|
||||
{% block alpine_data %}backgroundTasks(){% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/admin/js/background-tasks.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% call page_header_flex(title='Background Tasks', subtitle='Monitor running and completed background tasks') %}
|
||||
<!-- Flower Dashboard Link (Celery Monitoring) -->
|
||||
<a href="{{ flower_url }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple mr-2"
|
||||
title="Open Flower dashboard for detailed Celery task monitoring">
|
||||
<span x-html="$icon('chart-bar', 'w-4 h-4 mr-2')"></span>
|
||||
Flower Dashboard
|
||||
</a>
|
||||
{{ refresh_button(variant='secondary') }}
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading tasks...') }}
|
||||
|
||||
{{ error_state('Error loading tasks') }}
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<div x-show="!loading && !error">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Running Tasks -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
|
||||
<span x-html="$icon('refresh', 'w-5 h-5 animate-spin')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Running</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.running">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed Today -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Today</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.tasks_today">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Failed -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Failed</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.failed">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('collection', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_tasks">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Running Tasks Section -->
|
||||
<div class="mb-8" x-show="runningTasks.length > 0">
|
||||
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200 flex items-center">
|
||||
<span x-html="$icon('refresh', 'w-5 h-5 mr-2 animate-spin text-yellow-500')"></span>
|
||||
Currently Running
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
<template x-for="task in runningTasks" :key="task.task_type + '-' + task.id">
|
||||
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full mr-3"
|
||||
:class="{
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-700 dark:text-blue-100': task.task_type === 'import',
|
||||
'bg-purple-100 text-purple-700 dark:bg-purple-700 dark:text-purple-100': task.task_type === 'test_run'
|
||||
}"
|
||||
x-text="task.task_type === 'import' ? 'Import' : 'Test Run'">
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="task.description"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Started by <span x-text="task.triggered_by || 'system'"></span>
|
||||
at <span x-text="task.started_at ? new Date(task.started_at).toLocaleTimeString() : 'N/A'"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-bold text-yellow-600 dark:text-yellow-400" x-text="formatDuration(task.duration_seconds)"></p>
|
||||
<p class="text-xs text-gray-500">elapsed</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Type Stats -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2">
|
||||
<!-- Import Jobs Stats -->
|
||||
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200 flex items-center">
|
||||
<span x-html="$icon('cube', 'w-5 h-5 mr-2 text-blue-500')"></span>
|
||||
Import Jobs
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="stats.import_jobs?.total || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-yellow-600" x-text="stats.import_jobs?.running || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Running</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-green-600" x-text="stats.import_jobs?.completed || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Completed</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-red-600" x-text="stats.import_jobs?.failed || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Failed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 text-center">
|
||||
<a href="/admin/imports" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400">
|
||||
View Import Jobs →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Runs Stats -->
|
||||
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200 flex items-center">
|
||||
<span x-html="$icon('beaker', 'w-5 h-5 mr-2 text-purple-500')"></span>
|
||||
Test Runs
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-gray-700 dark:text-gray-200" x-text="stats.test_runs?.total || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-yellow-600" x-text="stats.test_runs?.running || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Running</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-green-600" x-text="stats.test_runs?.completed || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Passed</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-2xl font-bold text-red-600" x-text="stats.test_runs?.failed || 0"></p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Failed</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 text-center">
|
||||
<a href="/admin/testing" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400">
|
||||
View Test Dashboard →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<div class="mb-4">
|
||||
<div class="flex space-x-2">
|
||||
<button @click="filterType = null; loadTasks()"
|
||||
:class="filterType === null ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
All Tasks
|
||||
</button>
|
||||
<button @click="filterType = 'import'; loadTasks()"
|
||||
:class="filterType === 'import' ? 'bg-blue-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Imports
|
||||
</button>
|
||||
<button @click="filterType = 'test_run'; loadTasks()"
|
||||
:class="filterType === 'test_run' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Test Runs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks Table -->
|
||||
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Recent Tasks
|
||||
</h4>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full whitespace-nowrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Type</th>
|
||||
<th class="px-4 py-3">Description</th>
|
||||
<th class="px-4 py-3">Started</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Triggered By</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Celery</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="task in tasks" :key="task.task_type + '-' + task.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-700 dark:text-blue-100': task.task_type === 'import',
|
||||
'bg-purple-100 text-purple-700 dark:bg-purple-700 dark:text-purple-100': task.task_type === 'test_run'
|
||||
}"
|
||||
x-text="task.task_type === 'import' ? 'Import' : 'Test Run'">
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-medium truncate max-w-xs" x-text="task.description"></p>
|
||||
<p x-show="task.error_message" class="text-xs text-red-500 truncate max-w-xs" x-text="task.error_message"></p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm" x-text="task.started_at ? new Date(task.started_at).toLocaleString() : 'N/A'"></td>
|
||||
<td class="px-4 py-3 text-sm" x-text="formatDuration(task.duration_seconds)"></td>
|
||||
<td class="px-4 py-3 text-sm" x-text="task.triggered_by || 'system'"></td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-700 dark:bg-green-700 dark:text-green-100': task.status === 'completed' || task.status === 'passed',
|
||||
'bg-yellow-100 text-yellow-700 dark:bg-yellow-700 dark:text-yellow-100': task.status === 'running' || task.status === 'processing' || task.status === 'pending',
|
||||
'bg-red-100 text-red-700 dark:bg-red-700 dark:text-red-100': task.status === 'failed' || task.status === 'error',
|
||||
'bg-orange-100 text-orange-700 dark:bg-orange-700 dark:text-orange-100': task.status === 'completed_with_errors',
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-100': !['completed', 'passed', 'running', 'processing', 'pending', 'failed', 'error', 'completed_with_errors'].includes(task.status)
|
||||
}"
|
||||
x-text="task.status">
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<template x-if="task.celery_task_id">
|
||||
<a :href="'{{ flower_url }}/task/' + task.celery_task_id"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-300 underline"
|
||||
title="View in Flower">
|
||||
<span x-html="$icon('external-link', 'w-4 h-4 inline')"></span>
|
||||
<span x-text="task.celery_task_id.substring(0, 8) + '...'"></span>
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="!task.celery_task_id">
|
||||
<span class="text-gray-400 text-xs">-</span>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<template x-if="tasks.length === 0">
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('collection', 'w-12 h-12 mx-auto mb-2 text-gray-400')"></span>
|
||||
<p>No tasks found</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
263
app/modules/marketplace/templates/marketplace/admin/imports.html
Normal file
263
app/modules/marketplace/templates/marketplace/admin/imports.html
Normal file
@@ -0,0 +1,263 @@
|
||||
{# app/templates/admin/imports.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
{% from 'shared/macros/alerts.html' import error_state %}
|
||||
{% from 'shared/macros/modals.html' import job_details_modal %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
|
||||
|
||||
{% block title %}Import Jobs - Platform Monitoring{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminImports(){% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/admin/js/imports.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% call page_header_flex(title='Platform Import Jobs', subtitle='System-wide monitoring of all marketplace import jobs') %}
|
||||
{{ refresh_button(onclick='refreshJobs()') }}
|
||||
{% endcall %}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Total Jobs -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('cube', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Jobs
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Jobs -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
|
||||
<span x-html="$icon('clock', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Active Jobs
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Completed
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.completed">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Failed -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-white dark:bg-red-600">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Failed
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.failed">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ error_state('Error', show_condition='error') }}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 p-4">
|
||||
<div class="grid gap-4 md:grid-cols-5">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Vendor
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.vendor_id"
|
||||
@change="applyFilters()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Vendors</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.id">
|
||||
<option :value="vendor.id" x-text="`${vendor.name} (${vendor.vendor_code})`"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Status
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.status"
|
||||
@change="applyFilters()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="completed_with_errors">Completed with Errors</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Marketplace
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.marketplace"
|
||||
@change="applyFilters()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Marketplaces</option>
|
||||
<option value="Letzshop">Letzshop</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Created By
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.created_by"
|
||||
@change="applyFilters()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Users</option>
|
||||
<option value="me">My Jobs Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="clearFilters()"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Jobs List -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Import Jobs
|
||||
</h3>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading import jobs...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && jobs.length === 0" class="text-center py-12">
|
||||
<span x-html="$icon('inbox', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-gray-600 dark:text-gray-400">No import jobs found</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500">Try adjusting your filters or wait for new imports</p>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Table -->
|
||||
<div x-show="!loading && jobs.length > 0">
|
||||
{% call table_wrapper() %}
|
||||
{{ table_header(['Job ID', 'Vendor', 'Marketplace', 'Status', 'Progress', 'Started', 'Duration', 'Created By', 'Actions']) }}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="job in jobs" :key="job.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
#<span x-text="job.id"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="getVendorName(job.vendor_id)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.marketplace"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
|
||||
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
|
||||
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
|
||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors'
|
||||
}"
|
||||
x-text="job.status.replace('_', ' ').toUpperCase()">
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span class="text-green-600 dark:text-green-400" x-text="job.imported_count"></span> imported,
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="job.updated_count"></span> updated
|
||||
</div>
|
||||
<div x-show="job.error_count > 0" class="text-xs text-red-600 dark:text-red-400">
|
||||
<span x-text="job.error_count"></span> errors
|
||||
</div>
|
||||
<div x-show="job.total_processed > 0" class="text-xs text-gray-500 dark:text-gray-500">
|
||||
Total: <span x-text="job.total_processed"></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.started_at ? formatDate(job.started_at) : 'Not started'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="calculateDuration(job)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.created_by_name || 'System'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="viewJobDetails(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="job.status === 'processing' || job.status === 'pending'"
|
||||
@click="refreshJobStatus(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Refresh Status"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
{{ pagination(show_condition="!loading && pagination.total > 0") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ job_details_modal(show_created_by=true) }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,315 @@
|
||||
{# app/templates/admin/letzshop-order-detail.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex %}
|
||||
|
||||
{% block title %}Letzshop Order Details{% endblock %}
|
||||
{% block alpine_data %}letzshopOrderDetail(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="h-full pb-16 overflow-y-auto">
|
||||
<div class="container grid px-6 mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="/admin/marketplace/letzshop"
|
||||
class="flex items-center text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<span x-html="$icon('arrow-left', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Order <span x-text="order?.external_order_number || order?.order_number || 'Loading...'"></span>
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Letzshop Order Details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
x-show="order"
|
||||
class="px-3 py-1 text-sm rounded-full font-medium"
|
||||
:class="{
|
||||
'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300': order?.status === 'pending',
|
||||
'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300': order?.status === 'processing',
|
||||
'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300': order?.status === 'cancelled',
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300': order?.status === 'shipped'
|
||||
}"
|
||||
x-text="order?.status === 'cancelled' ? 'DECLINED' : (order?.status === 'processing' ? 'CONFIRMED' : order?.status?.toUpperCase())"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
{{ error_state('Failed to load order', 'error') }}
|
||||
|
||||
<!-- Order Content -->
|
||||
<div x-show="order && !loading" class="grid gap-6 mb-8 md:grid-cols-2">
|
||||
<!-- Order Information -->
|
||||
<div class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
|
||||
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
|
||||
Order Information
|
||||
</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Order Date</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="formatDate(order?.order_date || order?.created_at)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Order Number</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="order?.external_order_number || order?.order_number"></span>
|
||||
</div>
|
||||
<div class="flex justify-between" x-show="order?.shipment_number">
|
||||
<span class="text-gray-500 dark:text-gray-400">Shipment Number</span>
|
||||
<span class="font-mono font-medium text-gray-700 dark:text-gray-300" x-text="order?.shipment_number"></span>
|
||||
</div>
|
||||
<div class="flex justify-between" x-show="order?.external_shipment_id">
|
||||
<span class="text-gray-500 dark:text-gray-400">Hash ID</span>
|
||||
<span class="font-mono text-xs text-gray-600 dark:text-gray-400" x-text="order?.external_shipment_id?.split('/').pop()"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Total</span>
|
||||
<span class="font-semibold text-gray-700 dark:text-gray-300" x-text="order?.total_amount + ' ' + order?.currency"></span>
|
||||
</div>
|
||||
<div class="flex justify-between" x-show="order?.confirmed_at">
|
||||
<span class="text-gray-500 dark:text-gray-400">Confirmed At</span>
|
||||
<span class="text-gray-700 dark:text-gray-300" x-text="formatDate(order?.confirmed_at)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between" x-show="order?.cancelled_at">
|
||||
<span class="text-gray-500 dark:text-gray-400">Declined At</span>
|
||||
<span class="text-gray-700 dark:text-gray-300" x-text="formatDate(order?.cancelled_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Information -->
|
||||
<div class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
|
||||
<span x-html="$icon('user', 'w-5 h-5')"></span>
|
||||
Customer Information
|
||||
</h4>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Name</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="order?.customer_name || 'N/A'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Email</span>
|
||||
<a :href="'mailto:' + order?.customer_email" class="text-purple-600 hover:underline" x-text="order?.customer_email"></a>
|
||||
</div>
|
||||
<div class="flex justify-between" x-show="order?.customer_locale">
|
||||
<span class="text-gray-500 dark:text-gray-400">Language</span>
|
||||
<span class="uppercase font-medium text-gray-700 dark:text-gray-300" x-text="order?.customer_locale"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Address -->
|
||||
<div class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800" x-show="order">
|
||||
<h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
|
||||
<span x-html="$icon('location-marker', 'w-5 h-5')"></span>
|
||||
Shipping Address
|
||||
</h4>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300 space-y-1">
|
||||
<p x-text="order?.ship_first_name + ' ' + order?.ship_last_name"></p>
|
||||
<p x-show="order?.ship_company" x-text="order?.ship_company"></p>
|
||||
<p x-text="order?.ship_address_line_1"></p>
|
||||
<p x-show="order?.ship_address_line_2" x-text="order?.ship_address_line_2"></p>
|
||||
<p x-text="order?.ship_postal_code + ' ' + order?.ship_city"></p>
|
||||
<p x-text="order?.ship_country_iso"></p>
|
||||
<p x-show="order?.customer_phone" class="mt-2">
|
||||
<span class="text-gray-500 dark:text-gray-400">Phone:</span>
|
||||
<span x-text="order?.customer_phone"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping & Tracking Information -->
|
||||
<div class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
|
||||
<span x-html="$icon('truck', 'w-5 h-5')"></span>
|
||||
Shipping & Tracking
|
||||
</h4>
|
||||
<div x-show="order?.shipment_number || order?.shipping_carrier || order?.tracking_number || order?.tracking_url" class="space-y-3 text-sm">
|
||||
<div class="flex justify-between" x-show="order?.shipping_carrier">
|
||||
<span class="text-gray-500 dark:text-gray-400">Carrier</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 capitalize" x-text="order?.shipping_carrier"></span>
|
||||
</div>
|
||||
<div class="flex justify-between" x-show="order?.shipment_number">
|
||||
<span class="text-gray-500 dark:text-gray-400">Shipment Number</span>
|
||||
<span class="font-mono text-gray-700 dark:text-gray-300" x-text="order?.shipment_number"></span>
|
||||
</div>
|
||||
<div class="flex justify-between" x-show="order?.tracking_number">
|
||||
<span class="text-gray-500 dark:text-gray-400">Tracking Number</span>
|
||||
<span class="font-mono text-gray-700 dark:text-gray-300" x-text="order?.tracking_number"></span>
|
||||
</div>
|
||||
<div class="flex justify-between" x-show="order?.tracking_provider">
|
||||
<span class="text-gray-500 dark:text-gray-400">Tracking Provider</span>
|
||||
<span class="text-gray-700 dark:text-gray-300" x-text="order?.tracking_provider"></span>
|
||||
</div>
|
||||
<div class="flex justify-between" x-show="order?.shipped_at">
|
||||
<span class="text-gray-500 dark:text-gray-400">Shipped At</span>
|
||||
<span class="text-gray-700 dark:text-gray-300" x-text="formatDate(order?.shipped_at)"></span>
|
||||
</div>
|
||||
<!-- Tracking Link -->
|
||||
<div x-show="order?.tracking_url || (order?.shipping_carrier === 'greco' && order?.shipment_number)" class="pt-2 border-t dark:border-gray-700">
|
||||
<a
|
||||
:href="order?.tracking_url || ('https://dispatchweb.fr/Tracky/Home/' + order?.shipment_number)"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-purple-600 bg-purple-50 dark:bg-purple-900/30 dark:text-purple-400 rounded-lg hover:bg-purple-100 dark:hover:bg-purple-900/50 transition-colors"
|
||||
>
|
||||
<span x-html="$icon('external-link', 'w-4 h-4')"></span>
|
||||
View Tracking / Download Label
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="!order?.shipment_number && !order?.shipping_carrier && !order?.tracking_number && !order?.tracking_url" class="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No shipping information available yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Items -->
|
||||
<div x-show="order && !loading" class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 mb-8">
|
||||
<h4 class="mb-4 font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
|
||||
<span x-html="$icon('shopping-bag', 'w-5 h-5')"></span>
|
||||
Order Items
|
||||
<span class="text-sm font-normal text-gray-500">
|
||||
(<span x-text="order?.items?.length || 0"></span> items)
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full whitespace-nowrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Product</th>
|
||||
<th class="px-4 py-3">GTIN/SKU</th>
|
||||
<th class="px-4 py-3">Qty</th>
|
||||
<th class="px-4 py-3">Price</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="item in order?.items || []" :key="item.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center">
|
||||
<!-- Placeholder for product image -->
|
||||
<div class="w-10 h-10 rounded bg-gray-200 dark:bg-gray-600 flex items-center justify-center mr-3">
|
||||
<span x-html="$icon('photograph', 'w-5 h-5 text-gray-400')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-800 dark:text-gray-200" x-text="item.product_name || 'Unknown Product'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<p x-show="item.gtin" class="font-mono">
|
||||
<span x-text="item.gtin"></span>
|
||||
<span x-show="item.gtin_type" class="text-xs text-gray-400" x-text="'(' + item.gtin_type + ')'"></span>
|
||||
</p>
|
||||
<p x-show="item.product_sku" class="text-xs text-gray-500">
|
||||
SKU: <span x-text="item.product_sku"></span>
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="item.quantity"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-medium">
|
||||
<span x-text="item.unit_price ? (item.unit_price + ' ' + order?.currency) : 'N/A'"></span>
|
||||
<p x-show="item.quantity > 1" class="text-xs text-gray-500">
|
||||
Total: <span x-text="item.total_price + ' ' + order?.currency"></span>
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full font-medium"
|
||||
:class="{
|
||||
'bg-orange-100 text-orange-700': !item.item_state,
|
||||
'bg-green-100 text-green-700': item.item_state === 'confirmed_available',
|
||||
'bg-red-100 text-red-700': item.item_state === 'confirmed_unavailable',
|
||||
'bg-gray-100 text-gray-700': item.item_state === 'returned'
|
||||
}"
|
||||
x-text="item.item_state === 'confirmed_unavailable' ? 'DECLINED' : (item.item_state === 'confirmed_available' ? 'CONFIRMED' : (item.item_state ? item.item_state.toUpperCase() : 'PENDING'))"
|
||||
></span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw Order Data (collapsible) -->
|
||||
<div x-show="order && !loading" class="min-w-0 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 mb-8">
|
||||
<button
|
||||
@click="showRawData = !showRawData"
|
||||
class="w-full flex items-center justify-between text-left"
|
||||
>
|
||||
<h4 class="font-semibold text-gray-600 dark:text-gray-300 flex items-center gap-2">
|
||||
<span x-html="$icon('code', 'w-5 h-5')"></span>
|
||||
Raw Marketplace Data
|
||||
</h4>
|
||||
<span x-html="showRawData ? $icon('chevron-up', 'w-5 h-5 text-gray-500') : $icon('chevron-down', 'w-5 h-5 text-gray-500')"></span>
|
||||
</button>
|
||||
<div x-show="showRawData" class="mt-4">
|
||||
<pre class="text-xs bg-gray-100 dark:bg-gray-900 p-4 rounded overflow-x-auto max-h-96"><code x-text="JSON.stringify(order?.external_data, null, 2)"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function letzshopOrderDetail() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'marketplace-letzshop',
|
||||
orderId: {{ order_id }},
|
||||
order: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
showRawData: false,
|
||||
|
||||
async init() {
|
||||
await this.loadOrder();
|
||||
},
|
||||
|
||||
async loadOrder() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
// Fetch the order detail from the API
|
||||
const response = await apiClient.get(`/admin/letzshop/orders/${this.orderId}`);
|
||||
this.order = response;
|
||||
} catch (err) {
|
||||
console.error('Failed to load order:', err);
|
||||
this.error = err.message || 'Failed to load order details';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return new Date(dateString).toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,430 @@
|
||||
{# app/templates/admin/letzshop-vendor-directory.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
{% from 'shared/macros/pagination.html' import pagination_controls %}
|
||||
|
||||
{% block title %}Letzshop Vendor Directory{% endblock %}
|
||||
{% block alpine_data %}letzshopVendorDirectory(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
{% call page_header_flex(title='Letzshop Vendor Directory', subtitle='Browse and import vendors from Letzshop marketplace') %}
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="triggerSync()"
|
||||
:disabled="syncing"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
|
||||
>
|
||||
<span x-show="!syncing" x-html="$icon('arrow-path', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="syncing" class="w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
||||
<span x-text="syncing ? 'Syncing...' : 'Sync from Letzshop'"></span>
|
||||
</button>
|
||||
{{ refresh_button(loading_var='loading', onclick='loadVendors()', variant='secondary') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Success/Error Messages -->
|
||||
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-600 text-green-700 dark:text-green-300 rounded-lg flex items-center">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 flex-shrink-0')"></span>
|
||||
<span x-text="successMessage"></span>
|
||||
<button @click="successMessage = ''" class="ml-auto">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{ error_state('Error', show_condition='error && !loading') }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Total Vendors</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white" x-text="stats.total_vendors || 0"></p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
|
||||
<span x-html="$icon('building-storefront', 'w-5 h-5 text-blue-600 dark:text-blue-400')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Active</p>
|
||||
<p class="text-2xl font-bold text-green-600 dark:text-green-400" x-text="stats.active_vendors || 0"></p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 text-green-600 dark:text-green-400')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Claimed</p>
|
||||
<p class="text-2xl font-bold text-purple-600 dark:text-purple-400" x-text="stats.claimed_vendors || 0"></p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
||||
<span x-html="$icon('user-check', 'w-5 h-5 text-purple-600 dark:text-purple-400')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Unclaimed</p>
|
||||
<p class="text-2xl font-bold text-amber-600 dark:text-amber-400" x-text="stats.unclaimed_vendors || 0"></p>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-lg flex items-center justify-center">
|
||||
<span x-html="$icon('user-plus', 'w-5 h-5 text-amber-600 dark:text-amber-400')"></span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2" x-show="stats.last_synced_at">
|
||||
Last sync: <span x-text="formatDate(stats.last_synced_at)"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<!-- Search -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.search"
|
||||
@input.debounce.300ms="loadVendors()"
|
||||
placeholder="Search by name..."
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
</div>
|
||||
<!-- City -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.city"
|
||||
@input.debounce.300ms="loadVendors()"
|
||||
placeholder="Filter by city..."
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
</div>
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.category"
|
||||
@input.debounce.300ms="loadVendors()"
|
||||
placeholder="Filter by category..."
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
</div>
|
||||
<!-- Only Unclaimed -->
|
||||
<div class="flex items-end">
|
||||
<label class="inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="filters.only_unclaimed"
|
||||
@change="loadVendors()"
|
||||
class="sr-only peer"
|
||||
>
|
||||
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-purple-600"></div>
|
||||
<span class="ms-3 text-sm font-medium text-gray-700 dark:text-gray-300">Only Unclaimed</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex justify-center items-center py-12">
|
||||
<div class="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
|
||||
<!-- Vendors Table -->
|
||||
<div x-show="!loading" x-cloak class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<!-- Empty State -->
|
||||
<div x-show="vendors.length === 0" class="text-center py-12">
|
||||
<span x-html="$icon('building-storefront', 'w-12 h-12 mx-auto text-gray-400')"></span>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No vendors found</h3>
|
||||
<p class="mt-2 text-gray-500 dark:text-gray-400">
|
||||
<span x-show="stats.total_vendors === 0">Click "Sync from Letzshop" to import vendors.</span>
|
||||
<span x-show="stats.total_vendors > 0">Try adjusting your filters.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div x-show="vendors.length > 0" class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900/50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Vendor</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Contact</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Location</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Categories</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="vendor in vendors" :key="vendor.id">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-400" x-text="vendor.name?.charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white" x-text="vendor.name"></div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400" x-text="vendor.company_name"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-900 dark:text-white" x-text="vendor.email || '-'"></div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400" x-text="vendor.phone || ''"></div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-900 dark:text-white" x-text="vendor.city || '-'"></div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<template x-for="cat in (vendor.categories || []).slice(0, 2)" :key="cat">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200" x-text="cat"></span>
|
||||
</template>
|
||||
<span x-show="(vendor.categories || []).length > 2" class="text-xs text-gray-500">+<span x-text="vendor.categories.length - 2"></span></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
x-show="vendor.is_claimed"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300"
|
||||
>
|
||||
<span x-html="$icon('check', 'w-3 h-3 mr-1')"></span>
|
||||
Claimed
|
||||
</span>
|
||||
<span
|
||||
x-show="!vendor.is_claimed"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300"
|
||||
>
|
||||
Available
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<a
|
||||
:href="vendor.letzshop_url"
|
||||
target="_blank"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="View on Letzshop"
|
||||
>
|
||||
<span x-html="$icon('arrow-top-right-on-square', 'w-5 h-5')"></span>
|
||||
</a>
|
||||
<button
|
||||
@click="showVendorDetail(vendor)"
|
||||
class="text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-300"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="!vendor.is_claimed"
|
||||
@click="openCreateVendorModal(vendor)"
|
||||
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300"
|
||||
title="Create Platform Vendor"
|
||||
>
|
||||
<span x-html="$icon('plus-circle', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div x-show="vendors.length > 0" class="px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing <span x-text="((page - 1) * limit) + 1"></span> to <span x-text="Math.min(page * limit, total)"></span> of <span x-text="total"></span> vendors
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="page--; loadVendors()"
|
||||
:disabled="page <= 1"
|
||||
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span class="px-3 py-1 text-sm">Page <span x-text="page"></span></span>
|
||||
<button
|
||||
@click="page++; loadVendors()"
|
||||
:disabled="!hasMore"
|
||||
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vendor Detail Modal -->
|
||||
<div
|
||||
x-show="showDetailModal"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
@keydown.escape.window="showDetailModal = false"
|
||||
>
|
||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
|
||||
<div x-show="showDetailModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75" @click="showDetailModal = false"></div>
|
||||
|
||||
<div x-show="showDetailModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" class="relative inline-block w-full max-w-2xl p-6 my-8 text-left align-middle bg-white dark:bg-gray-800 rounded-xl shadow-xl">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white" x-text="selectedVendor?.name"></h3>
|
||||
<button @click="showDetailModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('x', 'w-6 h-6')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="selectedVendor" class="space-y-4">
|
||||
<!-- Company Name -->
|
||||
<div x-show="selectedVendor?.company_name">
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Company</p>
|
||||
<p class="text-gray-900 dark:text-white" x-text="selectedVendor?.company_name"></p>
|
||||
</div>
|
||||
|
||||
<!-- Contact -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Email</p>
|
||||
<p class="text-gray-900 dark:text-white" x-text="selectedVendor?.email || '-'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Phone</p>
|
||||
<p class="text-gray-900 dark:text-white" x-text="selectedVendor?.phone || '-'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address -->
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</p>
|
||||
<p class="text-gray-900 dark:text-white">
|
||||
<span x-text="selectedVendor?.city || '-'"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div x-show="selectedVendor?.categories?.length">
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Categories</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="cat in (selectedVendor?.categories || [])" :key="cat">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300" x-text="cat"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Website -->
|
||||
<div x-show="selectedVendor?.website">
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Website</p>
|
||||
<a :href="selectedVendor?.website" target="_blank" class="text-purple-600 hover:text-purple-800 dark:text-purple-400" x-text="selectedVendor?.website"></a>
|
||||
</div>
|
||||
|
||||
<!-- Letzshop URL -->
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Letzshop Page</p>
|
||||
<a :href="selectedVendor?.letzshop_url" target="_blank" class="text-purple-600 hover:text-purple-800 dark:text-purple-400" x-text="selectedVendor?.letzshop_url"></a>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button @click="showDetailModal = false" class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg">
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
x-show="!selectedVendor?.is_claimed"
|
||||
@click="showDetailModal = false; openCreateVendorModal(selectedVendor)"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 rounded-lg"
|
||||
>
|
||||
Create Vendor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Vendor Modal -->
|
||||
<div
|
||||
x-show="showCreateModal"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
@keydown.escape.window="showCreateModal = false"
|
||||
>
|
||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0">
|
||||
<div x-show="showCreateModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75" @click="showCreateModal = false"></div>
|
||||
|
||||
<div x-show="showCreateModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" class="relative inline-block w-full max-w-md p-6 my-8 text-left align-middle bg-white dark:bg-gray-800 rounded-xl shadow-xl">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Create Vendor from Letzshop</h3>
|
||||
<button @click="showCreateModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('x', 'w-6 h-6')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Create a platform vendor from <strong x-text="createVendorData?.name"></strong>
|
||||
</p>
|
||||
|
||||
<!-- Company Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Select Company <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
x-model="createVendorData.company_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">-- Select a company --</option>
|
||||
<template x-for="company in companies" :key="company.id">
|
||||
<option :value="company.id" x-text="company.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">The vendor will be created under this company</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div x-show="createError" class="p-3 bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-300 rounded-lg text-sm">
|
||||
<span x-text="createError"></span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="pt-4 flex justify-end gap-3">
|
||||
<button @click="showCreateModal = false" class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="createVendor()"
|
||||
:disabled="!createVendorData.company_id || creating"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg"
|
||||
>
|
||||
<span x-show="!creating">Create Vendor</span>
|
||||
<span x-show="creating" class="flex items-center">
|
||||
<span class="w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
||||
Creating...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('marketplace_static', path='admin/js/letzshop-vendor-directory.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,359 @@
|
||||
{# app/templates/admin/letzshop.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
{% from 'shared/macros/alerts.html' import error_state, alert_dynamic %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
|
||||
{% from 'shared/macros/modals.html' import modal %}
|
||||
|
||||
{% block title %}Letzshop Management{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminLetzshop(){% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/admin/js/letzshop.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
{% call page_header_flex(title='Letzshop Management', subtitle='Manage Letzshop integration for all vendors') %}
|
||||
{{ refresh_button(loading_var='loading', onclick='refreshData()') }}
|
||||
{% endcall %}
|
||||
|
||||
<!-- Success Message -->
|
||||
{{ alert_dynamic(type='success', title='', message_var='successMessage', show_condition='successMessage') }}
|
||||
|
||||
<!-- Error Message -->
|
||||
{{ error_state(title='Error', error_var='error', show_condition='error && !loading') }}
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-4">
|
||||
<!-- Total Vendors -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:bg-purple-900">
|
||||
<span x-html="$icon('office-building', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Vendors</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configured -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:bg-green-900">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Configured</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.configured"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-Sync Enabled -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:bg-blue-900">
|
||||
<span x-html="$icon('refresh', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Auto-Sync</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.autoSync"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Orders -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:bg-orange-900">
|
||||
<span x-html="$icon('clock', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Pending Orders</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.pendingOrders"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 flex flex-wrap gap-4">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="filters.configuredOnly"
|
||||
@change="loadVendors()"
|
||||
class="form-checkbox h-4 w-4 text-purple-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Configured only</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Vendors Table -->
|
||||
{% call table_wrapper() %}
|
||||
{{ table_header(['Vendor', 'Status', 'Auto-Sync', 'Last Sync', 'Orders', 'Actions']) }}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="loading && vendors.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
|
||||
<p>Loading vendors...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-if="!loading && vendors.length === 0">
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('office-building', 'w-12 h-12 mx-auto mb-2 text-gray-300')"></span>
|
||||
<p class="font-medium">No vendors found</p>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="vendor in vendors" :key="vendor.vendor_id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<div class="relative hidden w-8 h-8 mr-3 rounded-full md:block">
|
||||
<div class="absolute inset-0 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="vendor.vendor_name.charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="vendor.vendor_name"></p>
|
||||
<p class="text-xs text-gray-500" x-text="vendor.vendor_code"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span
|
||||
class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="vendor.is_configured ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-gray-700 bg-gray-100 dark:bg-gray-600 dark:text-gray-100'"
|
||||
x-text="vendor.is_configured ? 'CONFIGURED' : 'NOT CONFIGURED'"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-show="vendor.auto_sync_enabled" class="text-green-600 dark:text-green-400">
|
||||
<span x-html="$icon('check', 'w-4 h-4 inline')"></span> Enabled
|
||||
</span>
|
||||
<span x-show="!vendor.auto_sync_enabled" class="text-gray-400">
|
||||
<span x-html="$icon('x', 'w-4 h-4 inline')"></span> Disabled
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div x-show="vendor.last_sync_at">
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-700': vendor.last_sync_status === 'success',
|
||||
'bg-yellow-100 text-yellow-700': vendor.last_sync_status === 'partial',
|
||||
'bg-red-100 text-red-700': vendor.last_sync_status === 'failed'
|
||||
}"
|
||||
x-text="vendor.last_sync_status"
|
||||
></span>
|
||||
<p class="text-xs text-gray-500 mt-1" x-text="formatDate(vendor.last_sync_at)"></p>
|
||||
</div>
|
||||
<span x-show="!vendor.last_sync_at" class="text-gray-400">Never</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded-full bg-orange-100 text-orange-700"
|
||||
x-show="vendor.pending_orders > 0"
|
||||
x-text="vendor.pending_orders + ' pending'"
|
||||
></span>
|
||||
<span class="text-gray-500" x-text="vendor.total_orders + ' total'"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<button
|
||||
@click="openConfigModal(vendor)"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-purple-600 transition-colors duration-150 rounded-md hover:bg-purple-100 dark:hover:bg-purple-900"
|
||||
title="Configure"
|
||||
>
|
||||
<span x-html="$icon('cog', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="vendor.is_configured"
|
||||
@click="testConnection(vendor)"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-blue-600 transition-colors duration-150 rounded-md hover:bg-blue-100 dark:hover:bg-blue-900"
|
||||
title="Test Connection"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="vendor.is_configured"
|
||||
@click="triggerSync(vendor)"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-green-600 transition-colors duration-150 rounded-md hover:bg-green-100 dark:hover:bg-green-900"
|
||||
title="Trigger Sync"
|
||||
>
|
||||
<span x-html="$icon('download', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="vendor.total_orders > 0"
|
||||
@click="viewOrders(vendor)"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-gray-600 transition-colors duration-150 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View Orders"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Pagination -->
|
||||
<div x-show="totalVendors > limit" class="mt-4 grid px-4 py-3 text-xs font-semibold tracking-wide text-gray-500 uppercase border dark:border-gray-700 rounded-lg bg-gray-50 sm:grid-cols-9 dark:text-gray-400 dark:bg-gray-800">
|
||||
<span class="flex items-center col-span-3">
|
||||
Showing <span x-text="((page - 1) * limit) + 1"></span>-<span x-text="Math.min(page * limit, totalVendors)"></span> of <span x-text="totalVendors"></span>
|
||||
</span>
|
||||
<span class="col-span-2"></span>
|
||||
<span class="flex col-span-4 mt-2 sm:mt-auto sm:justify-end">
|
||||
<nav>
|
||||
<ul class="inline-flex items-center">
|
||||
<li>
|
||||
<button @click="page--; loadVendors()" :disabled="page <= 1" class="px-3 py-1 rounded-md disabled:opacity-50">
|
||||
<span x-html="$icon('chevron-left', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="page++; loadVendors()" :disabled="page * limit >= totalVendors" class="px-3 py-1 rounded-md disabled:opacity-50">
|
||||
<span x-html="$icon('chevron-right', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Modal -->
|
||||
{% call modal('configModal', 'Configure Letzshop', 'showConfigModal', size='md') %}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Configuring: <span class="font-semibold" x-text="selectedVendor?.vendor_name"></span>
|
||||
</p>
|
||||
<form @submit.prevent="saveVendorConfig()">
|
||||
<div class="space-y-4 mb-6">
|
||||
<!-- API Key -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
API Key <span x-show="!vendorCredentials" class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
:type="showApiKey ? 'text' : 'password'"
|
||||
x-model="configForm.api_key"
|
||||
:placeholder="vendorCredentials ? vendorCredentials.api_key_masked : 'Enter API key'"
|
||||
class="block w-full px-3 py-2 pr-10 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
<button type="button" @click="showApiKey = !showApiKey" class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400">
|
||||
<span x-html="$icon(showApiKey ? 'eye-off' : 'eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto Sync -->
|
||||
<div>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="configForm.auto_sync_enabled"
|
||||
class="form-checkbox h-5 w-5 text-purple-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Enable Auto-Sync</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Sync Interval -->
|
||||
<div x-show="configForm.auto_sync_enabled">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Sync Interval
|
||||
</label>
|
||||
<select
|
||||
x-model="configForm.sync_interval_minutes"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="15">Every 15 minutes</option>
|
||||
<option value="30">Every 30 minutes</option>
|
||||
<option value="60">Every hour</option>
|
||||
<option value="120">Every 2 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
type="button"
|
||||
x-show="vendorCredentials"
|
||||
@click="deleteVendorConfig()"
|
||||
class="px-4 py-2 text-sm font-medium text-red-600 border border-red-300 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
<div class="flex gap-3 ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
@click="showConfigModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="savingConfig"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="savingConfig" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="savingConfig ? 'Saving...' : 'Save'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Orders Modal -->
|
||||
{% call modal('ordersModal', 'Vendor Orders', 'showOrdersModal', size='xl') %}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Orders for: <span class="font-semibold" x-text="selectedVendor?.vendor_name"></span>
|
||||
</p>
|
||||
|
||||
<div x-show="loadingOrders" class="py-8 text-center">
|
||||
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto')"></span>
|
||||
</div>
|
||||
|
||||
<div x-show="!loadingOrders">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-gray-500 border-b dark:border-gray-700">
|
||||
<th class="pb-2">Order</th>
|
||||
<th class="pb-2">Customer</th>
|
||||
<th class="pb-2">Total</th>
|
||||
<th class="pb-2">Status</th>
|
||||
<th class="pb-2">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y dark:divide-gray-700">
|
||||
<template x-for="order in vendorOrders" :key="order.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="py-2" x-text="order.letzshop_order_number || order.letzshop_order_id"></td>
|
||||
<td class="py-2" x-text="order.customer_email || 'N/A'"></td>
|
||||
<td class="py-2" x-text="order.total_amount ? order.total_amount + ' ' + order.currency : 'N/A'"></td>
|
||||
<td class="py-2">
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded-full"
|
||||
:class="{
|
||||
'bg-orange-100 text-orange-700': order.sync_status === 'pending',
|
||||
'bg-green-100 text-green-700': order.sync_status === 'confirmed',
|
||||
'bg-red-100 text-red-700': order.sync_status === 'rejected',
|
||||
'bg-blue-100 text-blue-700': order.sync_status === 'shipped'
|
||||
}"
|
||||
x-text="order.sync_status"
|
||||
></span>
|
||||
</td>
|
||||
<td class="py-2" x-text="formatDate(order.created_at)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<p x-show="vendorOrders.length === 0" class="py-4 text-center text-gray-500">No orders found</p>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,603 @@
|
||||
{# app/templates/admin/marketplace-letzshop.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/tabs.html' import tabs_nav, tab_button, tab_panel, endtab_panel %}
|
||||
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
{# Import modals macro - custom modals below use inline definition for specialized forms #}
|
||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||
|
||||
{% block title %}Letzshop Management{% endblock %}
|
||||
{% block alpine_data %}adminMarketplaceLetzshop(){% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Tom Select CSS with local fallback -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.default.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/vendor/tom-select.default.min.css') }}';"
|
||||
/>
|
||||
<style>
|
||||
/* Tom Select dark mode overrides */
|
||||
.dark .ts-wrapper .ts-control {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input::placeholder {
|
||||
color: rgb(156 163 175);
|
||||
}
|
||||
.dark .ts-dropdown {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option.active {
|
||||
background-color: rgb(147 51 234);
|
||||
color: white;
|
||||
}
|
||||
.dark .ts-dropdown .option:hover {
|
||||
background-color: rgb(75 85 99);
|
||||
}
|
||||
.dark .ts-wrapper.focus .ts-control {
|
||||
border-color: rgb(147 51 234);
|
||||
box-shadow: 0 0 0 1px rgb(147 51 234);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header with Vendor Selector -->
|
||||
{% call page_header_flex(title='Letzshop Management', subtitle='Manage Letzshop integration for vendors') %}
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Vendor Autocomplete (Tom Select) -->
|
||||
<div class="w-80">
|
||||
<select id="vendor-select" x-ref="vendorSelect" placeholder="Search vendor...">
|
||||
</select>
|
||||
</div>
|
||||
{{ refresh_button(loading_var='loading', onclick='refreshData()', variant='secondary') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-600 text-green-700 dark:text-green-300 rounded-lg flex items-start">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="successMessage"></p>
|
||||
</div>
|
||||
<button @click="successMessage = ''" class="ml-auto text-green-700 dark:text-green-300 hover:text-green-900">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
{{ error_state('Error', show_condition='error && !loading') }}
|
||||
|
||||
<!-- Cross-vendor info banner (shown when no vendor selected) -->
|
||||
<div x-show="!selectedVendor && !loading" class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('information-circle', 'w-6 h-6 text-blue-500 mr-3')"></span>
|
||||
<div>
|
||||
<h3 class="font-medium text-blue-800 dark:text-blue-200">All Vendors View</h3>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">Showing data across all vendors. Select a vendor above to manage products, import orders, or access settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div x-show="!loading" x-transition x-cloak>
|
||||
<!-- Selected Vendor Filter (same pattern as orders page) -->
|
||||
<div x-show="selectedVendor" x-transition class="mb-6 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="selectedVendor?.name?.charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div>
|
||||
<span class="font-medium text-purple-800 dark:text-purple-200" x-text="selectedVendor?.name"></span>
|
||||
<span class="ml-2 text-xs text-purple-600 dark:text-purple-400 font-mono" x-text="selectedVendor?.vendor_code"></span>
|
||||
</div>
|
||||
<!-- Status badges -->
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="letzshopStatus.is_configured ? 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
|
||||
x-text="letzshopStatus.is_configured ? 'Configured' : 'Not Configured'">
|
||||
</span>
|
||||
<span x-show="letzshopStatus.auto_sync_enabled" class="px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300">
|
||||
Auto-sync
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="clearVendorSelection()" class="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 text-sm flex items-center gap-1">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
{% call tabs_nav(tab_var='activeTab') %}
|
||||
{{ tab_button('products', 'Products', tab_var='activeTab', icon='cube') }}
|
||||
{{ tab_button('orders', 'Orders', tab_var='activeTab', icon='shopping-cart', count_var='orderStats.pending') }}
|
||||
{{ tab_button('exceptions', 'Exceptions', tab_var='activeTab', icon='exclamation-circle', count_var='exceptionStats.pending') }}
|
||||
{{ tab_button('jobs', 'Jobs', tab_var='activeTab', icon='collection') }}
|
||||
<template x-if="selectedVendor">
|
||||
<span>{{ tab_button('settings', 'Settings', tab_var='activeTab', icon='cog') }}</span>
|
||||
</template>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Products Tab -->
|
||||
{{ tab_panel('products', tab_var='activeTab') }}
|
||||
{% include 'marketplace/admin/partials/letzshop-products-tab.html' %}
|
||||
{{ endtab_panel() }}
|
||||
|
||||
<!-- Orders Tab -->
|
||||
{{ tab_panel('orders', tab_var='activeTab') }}
|
||||
{% include 'marketplace/admin/partials/letzshop-orders-tab.html' %}
|
||||
{{ endtab_panel() }}
|
||||
|
||||
<!-- Settings Tab - Vendor only -->
|
||||
<template x-if="selectedVendor">
|
||||
{{ tab_panel('settings', tab_var='activeTab') }}
|
||||
{% include 'marketplace/admin/partials/letzshop-settings-tab.html' %}
|
||||
{{ endtab_panel() }}
|
||||
</template>
|
||||
|
||||
<!-- Exceptions Tab -->
|
||||
{{ tab_panel('exceptions', tab_var='activeTab') }}
|
||||
{% include 'marketplace/admin/partials/letzshop-exceptions-tab.html' %}
|
||||
{{ endtab_panel() }}
|
||||
|
||||
<!-- Jobs Tab -->
|
||||
{{ tab_panel('jobs', tab_var='activeTab') }}
|
||||
{% include 'marketplace/admin/partials/letzshop-jobs-table.html' %}
|
||||
{{ endtab_panel() }}
|
||||
</div>
|
||||
|
||||
<!-- Tracking Modal -->
|
||||
<div
|
||||
x-show="showTrackingModal"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
@click.self="showTrackingModal = false"
|
||||
x-cloak
|
||||
>
|
||||
<div
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 transform translate-y-1/2"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0 transform translate-y-1/2"
|
||||
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-md"
|
||||
@click.stop
|
||||
>
|
||||
<header class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Set Tracking Information</h3>
|
||||
<button @click="showTrackingModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form @submit.prevent="submitTracking()">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Tracking Number <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="trackingForm.tracking_number"
|
||||
required
|
||||
placeholder="1Z999AA10123456784"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Carrier <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
x-model="trackingForm.tracking_provider"
|
||||
required
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">Select carrier...</option>
|
||||
<option value="dhl">DHL</option>
|
||||
<option value="ups">UPS</option>
|
||||
<option value="fedex">FedEx</option>
|
||||
<option value="post_lu">Post Luxembourg</option>
|
||||
<option value="dpd">DPD</option>
|
||||
<option value="gls">GLS</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="showTrackingModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submittingTracking"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="submittingTracking" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="submittingTracking ? 'Saving...' : 'Save Tracking'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Details Modal -->
|
||||
<div
|
||||
x-show="showOrderModal"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
@click.self="showOrderModal = false"
|
||||
x-cloak
|
||||
>
|
||||
<div
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 transform translate-y-1/2"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0 transform translate-y-1/2"
|
||||
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-2xl max-h-[80vh] overflow-y-auto"
|
||||
@click.stop
|
||||
>
|
||||
<header class="flex justify-between items-center mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Order Details</h3>
|
||||
<a
|
||||
:href="'/admin/letzshop/orders/' + selectedOrder?.id"
|
||||
class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400 flex items-center gap-1"
|
||||
title="Open full order page"
|
||||
>
|
||||
<span x-html="$icon('external-link', 'w-3 h-3')"></span>
|
||||
Full View
|
||||
</a>
|
||||
</div>
|
||||
<button @click="showOrderModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div x-show="selectedOrder" class="space-y-4">
|
||||
<!-- Order Info Grid -->
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-gray-600 dark:text-gray-400">Order Number:</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-300" x-text="selectedOrder?.external_order_number || selectedOrder?.order_number"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-600 dark:text-gray-400">Order Date:</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-300" x-text="formatDate(selectedOrder?.order_date || selectedOrder?.created_at)"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-600 dark:text-gray-400">Status:</span>
|
||||
<span
|
||||
class="ml-2 px-2 py-0.5 text-xs rounded-full"
|
||||
:class="{
|
||||
'bg-orange-100 text-orange-700': selectedOrder?.status === 'pending',
|
||||
'bg-green-100 text-green-700': selectedOrder?.status === 'processing',
|
||||
'bg-red-100 text-red-700': selectedOrder?.status === 'cancelled',
|
||||
'bg-blue-100 text-blue-700': selectedOrder?.status === 'shipped'
|
||||
}"
|
||||
x-text="selectedOrder?.status === 'cancelled' ? 'DECLINED' : (selectedOrder?.status === 'processing' ? 'CONFIRMED' : selectedOrder?.status?.toUpperCase())"
|
||||
></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-600 dark:text-gray-400">Total:</span>
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-300" x-text="selectedOrder?.total_amount + ' ' + selectedOrder?.currency"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer & Shipping Info -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-600 pt-4">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
||||
<span x-html="$icon('user', 'w-4 h-4')"></span>
|
||||
Customer & Shipping
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
<span class="font-medium">Name:</span>
|
||||
<span class="ml-1" x-text="selectedOrder?.customer_name || 'N/A'"></span>
|
||||
</p>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
<span class="font-medium">Email:</span>
|
||||
<a :href="'mailto:' + selectedOrder?.customer_email" class="ml-1 text-purple-600 hover:underline" x-text="selectedOrder?.customer_email"></a>
|
||||
</p>
|
||||
<p class="text-gray-600 dark:text-gray-400" x-show="selectedOrder?.customer_locale">
|
||||
<span class="font-medium">Language:</span>
|
||||
<span class="ml-1 uppercase" x-text="selectedOrder?.customer_locale"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div x-show="selectedOrder?.shipping_country_iso">
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
<span class="font-medium">Ship to:</span>
|
||||
<span class="ml-1" x-text="selectedOrder?.shipping_country_iso"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tracking Info -->
|
||||
<div x-show="selectedOrder?.tracking_number" class="border-t border-gray-200 dark:border-gray-600 pt-4">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
||||
<span x-html="$icon('truck', 'w-4 h-4')"></span>
|
||||
Tracking
|
||||
</h4>
|
||||
<div class="text-sm bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<span class="font-medium">Carrier:</span>
|
||||
<span class="ml-1" x-text="selectedOrder?.tracking_provider"></span>
|
||||
</p>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<span class="font-medium">Tracking #:</span>
|
||||
<span class="ml-1 font-mono" x-text="selectedOrder?.tracking_number"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Items -->
|
||||
<div x-show="selectedOrder?.items?.length > 0" class="border-t border-gray-200 dark:border-gray-600 pt-4">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
||||
<span x-html="$icon('shopping-bag', 'w-4 h-4')"></span>
|
||||
Items
|
||||
<span class="text-xs font-normal text-gray-500">
|
||||
(<span x-text="selectedOrder?.items?.length"></span> item<span x-show="selectedOrder?.items?.length > 1">s</span>)
|
||||
</span>
|
||||
</h4>
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<template x-for="(item, index) in selectedOrder?.items || []" :key="item.id">
|
||||
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-700 dark:text-gray-200 text-sm" x-text="item.product_name || 'Unknown Product'"></p>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 space-y-0.5">
|
||||
<p x-show="item.gtin">
|
||||
<span class="font-medium">GTIN:</span> <span x-text="item.gtin"></span>
|
||||
<span x-show="item.gtin_type" class="text-gray-400" x-text="'(' + item.gtin_type + ')'"></span>
|
||||
</p>
|
||||
<p x-show="item.product_sku"><span class="font-medium">SKU:</span> <span x-text="item.product_sku"></span></p>
|
||||
<p><span class="font-medium">Qty:</span> <span x-text="item.quantity"></span></p>
|
||||
<p x-show="item.unit_price"><span class="font-medium">Price:</span> <span x-text="item.unit_price + ' ' + selectedOrder?.currency"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<!-- Item State Badge -->
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs rounded-full whitespace-nowrap"
|
||||
:class="{
|
||||
'bg-orange-100 text-orange-700': !item.item_state,
|
||||
'bg-green-100 text-green-700': item.item_state === 'confirmed_available',
|
||||
'bg-red-100 text-red-700': item.item_state === 'confirmed_unavailable',
|
||||
'bg-gray-100 text-gray-700': item.item_state === 'returned'
|
||||
}"
|
||||
x-text="item.item_state === 'confirmed_unavailable' ? 'DECLINED' : (item.item_state === 'confirmed_available' ? 'CONFIRMED' : (item.item_state ? item.item_state.toUpperCase() : 'PENDING'))"
|
||||
></span>
|
||||
<!-- Item Actions (only for unconfirmed items) -->
|
||||
<template x-if="!item.item_state && selectedOrder?.status === 'pending'">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
@click="confirmInventoryUnit(selectedOrder, item, index)"
|
||||
class="p-1 text-green-600 hover:bg-green-100 rounded"
|
||||
title="Confirm this item"
|
||||
>
|
||||
<span x-html="$icon('check', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="declineInventoryUnit(selectedOrder, item, index)"
|
||||
class="p-1 text-red-600 hover:bg-red-100 rounded"
|
||||
title="Decline this item"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Bulk Actions -->
|
||||
<div x-show="selectedOrder?.status === 'pending'" class="mt-4 flex gap-2 justify-end">
|
||||
<button
|
||||
@click="confirmAllItems(selectedOrder)"
|
||||
class="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg"
|
||||
>
|
||||
Confirm All Items
|
||||
</button>
|
||||
<button
|
||||
@click="declineAllItems(selectedOrder)"
|
||||
class="px-3 py-1.5 text-sm text-white bg-red-600 hover:bg-red-700 rounded-lg"
|
||||
>
|
||||
Decline All Items
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exception Resolve Modal -->
|
||||
<div
|
||||
x-show="showResolveModal"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
@click.self="showResolveModal = false"
|
||||
x-cloak
|
||||
>
|
||||
<div
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 transform translate-y-1/2"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0 transform translate-y-1/2"
|
||||
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-lg"
|
||||
@click.stop
|
||||
>
|
||||
<header class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Resolve Exception</h3>
|
||||
<button @click="showResolveModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Exception Details -->
|
||||
<div class="mb-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="selectedExceptionForResolve?.original_product_name || 'Unknown Product'"></p>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<p x-show="selectedExceptionForResolve?.original_gtin">
|
||||
<span class="font-medium">GTIN:</span>
|
||||
<code class="ml-1 px-1 bg-gray-200 dark:bg-gray-600 rounded" x-text="selectedExceptionForResolve?.original_gtin"></code>
|
||||
</p>
|
||||
<p x-show="selectedExceptionForResolve?.original_sku">
|
||||
<span class="font-medium">SKU:</span> <span x-text="selectedExceptionForResolve?.original_sku"></span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-medium">Order:</span>
|
||||
<a :href="'/admin/orders/' + selectedExceptionForResolve?.order_id" class="text-purple-600 hover:underline" x-text="selectedExceptionForResolve?.order_number"></a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitResolveException()">
|
||||
<!-- Product Search -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Assign Product <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
x-model="productSearchQuery"
|
||||
@input.debounce.300ms="searchProducts()"
|
||||
placeholder="Search by name, SKU, or GTIN..."
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
<span x-show="searchingProducts" x-html="$icon('spinner', 'w-4 h-4 absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400')"></span>
|
||||
</div>
|
||||
<!-- Search Results -->
|
||||
<div x-show="productSearchResults.length > 0" class="mt-2 max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-md">
|
||||
<template x-for="product in productSearchResults" :key="product.id">
|
||||
<button
|
||||
type="button"
|
||||
@click="selectProductForResolve(product)"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-purple-50 dark:hover:bg-purple-900/20 border-b border-gray-100 dark:border-gray-700 last:border-b-0"
|
||||
:class="resolveForm.product_id === product.id ? 'bg-purple-100 dark:bg-purple-900/30' : ''"
|
||||
>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-200" x-text="product.name || product.title"></p>
|
||||
<p class="text-xs text-gray-500">
|
||||
<span x-show="product.gtin" x-text="'GTIN: ' + product.gtin"></span>
|
||||
<span x-show="product.gtin && product.sku"> · </span>
|
||||
<span x-show="product.sku" x-text="'SKU: ' + product.sku"></span>
|
||||
</p>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Selected Product -->
|
||||
<div x-show="resolveForm.product_id" class="mt-2 p-2 bg-green-50 dark:bg-green-900/20 rounded-md flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-700 dark:text-green-300" x-text="resolveForm.product_name"></p>
|
||||
<p class="text-xs text-green-600 dark:text-green-400" x-text="'Product ID: ' + resolveForm.product_id"></p>
|
||||
</div>
|
||||
<button type="button" @click="resolveForm.product_id = null; resolveForm.product_name = ''" class="text-green-600 hover:text-green-800">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolution Notes -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Notes (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
x-model="resolveForm.notes"
|
||||
rows="2"
|
||||
placeholder="Add any notes about this resolution..."
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Resolve Option -->
|
||||
<div x-show="selectedExceptionForResolve?.original_gtin" class="mb-4">
|
||||
<label class="flex items-center text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="resolveForm.bulk_resolve"
|
||||
class="mr-2 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
Resolve all pending exceptions with this GTIN
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="showResolveModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!resolveForm.product_id || submittingResolve"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="submittingResolve" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="submittingResolve ? 'Resolving...' : 'Resolve Exception'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<!-- Tom Select JS with local fallback -->
|
||||
<script>
|
||||
(function() {
|
||||
var script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/js/tom-select.complete.min.js';
|
||||
script.onerror = function() {
|
||||
console.warn('Tom Select CDN failed, loading local copy...');
|
||||
var fallbackScript = document.createElement('script');
|
||||
fallbackScript.src = '{{ url_for("static", path="shared/js/lib/tom-select.complete.min.js") }}';
|
||||
document.head.appendChild(fallbackScript);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('marketplace_static', path='admin/js/marketplace-letzshop.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,392 @@
|
||||
{# app/templates/admin/marketplace-product-detail.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/headers.html' import detail_page_header %}
|
||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||
|
||||
{% block title %}Marketplace Product Details{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminMarketplaceProductDetail(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% call detail_page_header("product?.title || 'Product Details'", back_url, subtitle_show='product') %}
|
||||
<span x-text="product?.marketplace || 'Unknown'"></span>
|
||||
<span class="text-gray-400 mx-2">|</span>
|
||||
<span x-text="'ID: ' + productId"></span>
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading product details...') }}
|
||||
|
||||
{{ error_state('Error loading product') }}
|
||||
|
||||
<!-- Product Details -->
|
||||
<div x-show="!loading && product">
|
||||
<!-- Quick Actions Card -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Quick Actions
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
@click="openCopyModal()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
<span x-html="$icon('duplicate', 'w-4 h-4 mr-2')"></span>
|
||||
Copy to Vendor Catalog
|
||||
</button>
|
||||
<a
|
||||
x-show="product?.source_url"
|
||||
:href="product?.source_url"
|
||||
target="_blank"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600">
|
||||
<span x-html="$icon('external-link', 'w-4 h-4 mr-2')"></span>
|
||||
View Source
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Header with Image -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-3">
|
||||
<!-- Product Image -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="aspect-square bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden">
|
||||
<template x-if="product?.image_link">
|
||||
<img :src="product?.image_link" :alt="product?.title" class="w-full h-full object-contain" />
|
||||
</template>
|
||||
<template x-if="!product?.image_link">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<span x-html="$icon('photograph', 'w-16 h-16 text-gray-300')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Additional Images -->
|
||||
<div x-show="product?.additional_images?.length > 0" class="mt-4">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-2">Additional Images</p>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<template x-for="(img, index) in (product?.additional_images || [])" :key="index">
|
||||
<div class="aspect-square bg-gray-100 dark:bg-gray-700 rounded overflow-hidden">
|
||||
<img :src="img" :alt="'Image ' + (index + 1)" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Info -->
|
||||
<div class="md:col-span-2 space-y-6">
|
||||
<!-- Basic Info Card -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Product Information
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Brand</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.brand || 'No brand'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Product Type</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="product?.is_digital ? 'text-blue-700 bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400' : 'text-orange-700 bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400'"
|
||||
x-text="product?.is_digital ? 'Digital' : 'Physical'">
|
||||
</span>
|
||||
<span x-show="product?.product_type_enum" class="text-xs text-gray-500" x-text="'(' + product?.product_type_enum + ')'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Condition</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.condition || 'Not specified'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Status</p>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full"
|
||||
:class="product?.is_active ? 'text-green-700 bg-green-100 dark:bg-green-900/30 dark:text-green-400' : 'text-red-700 bg-red-100 dark:bg-red-900/30 dark:text-red-400'"
|
||||
x-text="product?.is_active ? 'Active' : 'Inactive'">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing Card -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Pricing
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Price</p>
|
||||
<p class="text-lg font-bold text-gray-700 dark:text-gray-200" x-text="formatPrice(product?.price_numeric, product?.currency)">-</p>
|
||||
</div>
|
||||
<div x-show="product?.sale_price_numeric">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Sale Price</p>
|
||||
<p class="text-lg font-bold text-green-600 dark:text-green-400" x-text="formatPrice(product?.sale_price_numeric, product?.currency)">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Availability</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.availability || 'Not specified'">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Identifiers Card -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Product Identifiers
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Marketplace ID</p>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.marketplace_product_id || '-'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">GTIN/EAN</p>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.gtin || '-'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">MPN</p>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.mpn || '-'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">SKU</p>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.sku || '-'">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Source Information Card -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Source Information
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Marketplace</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.marketplace || 'Unknown'">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Source Vendor</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.vendor_name || 'Unknown'">-</p>
|
||||
</div>
|
||||
<div x-show="product?.platform">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Platform</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.platform">-</p>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="product?.source_url" class="mt-4">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase mb-1">Source URL</p>
|
||||
<a :href="product?.source_url" target="_blank" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 break-all" x-text="product?.source_url">-</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Information -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="product?.google_product_category || product?.category_path">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Categories
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div x-show="product?.google_product_category">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Google Product Category</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.google_product_category">-</p>
|
||||
</div>
|
||||
<div x-show="product?.category_path">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Category Path</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.category_path">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Physical Attributes -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="product?.color || product?.size || product?.weight">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Physical Attributes
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div x-show="product?.color">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Color</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.color">-</p>
|
||||
</div>
|
||||
<div x-show="product?.size">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Size</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.size">-</p>
|
||||
</div>
|
||||
<div x-show="product?.weight">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Weight</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
<span x-text="product?.weight"></span>
|
||||
<span x-text="product?.weight_unit || ''"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Translations Card with Tabs -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="product?.translations && Object.keys(product.translations).length > 0" x-data="{ activeTab: Object.keys(product?.translations || {})[0] || '' }">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Translations
|
||||
</h3>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span x-text="Object.keys(product?.translations || {}).length"></span> language(s)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Language Tabs -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||
<nav class="-mb-px flex space-x-4 overflow-x-auto" aria-label="Translation tabs">
|
||||
<template x-for="(trans, lang) in (product?.translations || {})" :key="lang">
|
||||
<button
|
||||
@click="activeTab = lang"
|
||||
:class="{
|
||||
'border-purple-500 text-purple-600 dark:text-purple-400': activeTab === lang,
|
||||
'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300': activeTab !== lang
|
||||
}"
|
||||
class="whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span class="uppercase" x-text="lang"></span>
|
||||
<span class="ml-1 text-xs text-gray-400" x-text="getLanguageName(lang)"></span>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<template x-for="(trans, lang) in (product?.translations || {})" :key="'content-' + lang">
|
||||
<div x-show="activeTab === lang" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
||||
<div class="space-y-4">
|
||||
<!-- Title -->
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Title</p>
|
||||
<button
|
||||
@click="copyToClipboard(trans?.title)"
|
||||
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<span x-html="$icon('clipboard', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-base font-medium text-gray-900 dark:text-gray-100" x-text="trans?.title || 'No title'"></p>
|
||||
</div>
|
||||
|
||||
<!-- Short Description -->
|
||||
<div x-show="trans?.short_description" class="bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Short Description</p>
|
||||
<button
|
||||
@click="copyToClipboard(trans?.short_description)"
|
||||
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<span x-html="$icon('clipboard', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="trans?.short_description"></p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div x-show="trans?.description" class="bg-gray-50 dark:bg-gray-700/50 p-4 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Description</p>
|
||||
<button
|
||||
@click="copyToClipboard(trans?.description)"
|
||||
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<span x-html="$icon('clipboard', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none max-h-96 overflow-y-auto" x-html="trans?.description || 'No description'"></div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state if no content -->
|
||||
<div x-show="!trans?.title && !trans?.short_description && !trans?.description" class="text-center py-8">
|
||||
<span x-html="$icon('document-text', 'w-12 h-12 mx-auto text-gray-300 dark:text-gray-600')"></span>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No translation content for this language</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Timestamps -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Record Information
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Created At</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(product?.created_at)">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Last Updated</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(product?.updated_at)">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Copy to Vendor Modal -->
|
||||
{% call modal_simple('copyToVendorModal', 'Copy to Vendor Catalog', show_var='showCopyModal', size='md') %}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Copy this product to a vendor's catalog.
|
||||
</p>
|
||||
|
||||
<!-- Target Vendor Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Target Vendor <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
x-model="copyForm.vendor_id"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">Select a vendor...</option>
|
||||
<template x-for="vendor in targetVendors" :key="vendor.id">
|
||||
<option :value="vendor.id" x-text="vendor.name + ' (' + vendor.vendor_code + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
The product will be copied to this vendor's catalog
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="copyForm.skip_existing"
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Skip if already exists in catalog</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="showCopyModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="executeCopyToVendor()"
|
||||
:disabled="!copyForm.vendor_id || copying"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="copying" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="copying ? 'Copying...' : 'Copy Product'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('marketplace_static', path='admin/js/marketplace-product-detail.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,476 @@
|
||||
{# app/templates/admin/marketplace-products.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
|
||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||
|
||||
{% block title %}Marketplace Products{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminMarketplaceProducts(){% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Tom Select CSS with local fallback -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/css/tom-select.default.min.css"
|
||||
onerror="this.onerror=null; this.href='{{ url_for('static', path='shared/css/vendor/tom-select.default.min.css') }}';"
|
||||
/>
|
||||
<style>
|
||||
/* Tom Select dark mode overrides */
|
||||
.dark .ts-wrapper .ts-control {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-wrapper .ts-control input::placeholder {
|
||||
color: rgb(156 163 175);
|
||||
}
|
||||
.dark .ts-dropdown {
|
||||
background-color: rgb(55 65 81);
|
||||
border-color: rgb(75 85 99);
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option {
|
||||
color: rgb(209 213 219);
|
||||
}
|
||||
.dark .ts-dropdown .option.active {
|
||||
background-color: rgb(147 51 234);
|
||||
color: white;
|
||||
}
|
||||
.dark .ts-dropdown .option:hover {
|
||||
background-color: rgb(75 85 99);
|
||||
}
|
||||
.dark .ts-wrapper.focus .ts-control {
|
||||
border-color: rgb(147 51 234);
|
||||
box-shadow: 0 0 0 1px rgb(147 51 234);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header with Vendor Selector -->
|
||||
{% call page_header_flex(title='Marketplace Products', subtitle='Master product repository - Browse all imported products from external sources') %}
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Vendor Autocomplete (Tom Select) -->
|
||||
<div class="w-80">
|
||||
<select id="vendor-select" x-ref="vendorSelect" placeholder="Filter by vendor...">
|
||||
</select>
|
||||
</div>
|
||||
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Selected Vendor Info -->
|
||||
<div x-show="selectedVendor" x-transition class="mb-6 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300" x-text="selectedVendor?.name?.charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-purple-800 dark:text-purple-200" x-text="selectedVendor?.name"></span>
|
||||
<span class="ml-2 text-xs text-purple-600 dark:text-purple-400 font-mono" x-text="selectedVendor?.vendor_code"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="clearVendorFilter()" class="text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-200 text-sm flex items-center gap-1">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ loading_state('Loading products...') }}
|
||||
|
||||
{{ error_state('Error loading products') }}
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-5">
|
||||
<!-- Card: Total Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('cube', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Products
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Active Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Active
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Inactive Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Inactive
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.inactive || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Digital Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('code', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Digital
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.digital || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Physical Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||
<span x-html="$icon('truck', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Physical
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.physical || 0">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters Bar -->
|
||||
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<!-- Search Input -->
|
||||
<div class="flex-1 max-w-xl">
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.search"
|
||||
@input="debouncedSearch()"
|
||||
placeholder="Search by title, GTIN, SKU, or brand..."
|
||||
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Marketplace Filter -->
|
||||
<select
|
||||
x-model="filters.marketplace"
|
||||
@change="pagination.page = 1; loadProducts()"
|
||||
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Marketplaces</option>
|
||||
<template x-for="mp in marketplaces" :key="mp">
|
||||
<option :value="mp" x-text="mp"></option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<!-- Product Type Filter -->
|
||||
<select
|
||||
x-model="filters.is_digital"
|
||||
@change="pagination.page = 1; loadProducts()"
|
||||
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="false">Physical</option>
|
||||
<option value="true">Digital</option>
|
||||
</select>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<select
|
||||
x-model="filters.is_active"
|
||||
@change="pagination.page = 1; loadProducts()"
|
||||
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Bar (shown when items selected) -->
|
||||
<div x-show="!loading && selectedProducts.length > 0"
|
||||
x-transition
|
||||
class="mb-4 p-4 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-purple-700 dark:text-purple-300">
|
||||
<span x-text="selectedProducts.length"></span> product(s) selected
|
||||
</span>
|
||||
<button
|
||||
@click="clearSelection()"
|
||||
class="text-sm text-purple-600 dark:text-purple-400 hover:underline"
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="openCopyToVendorModal()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<span x-html="$icon('duplicate', 'w-4 h-4 mr-2')"></span>
|
||||
Copy to Vendor Catalog
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products Table with Pagination -->
|
||||
<div x-show="!loading">
|
||||
{% call table_wrapper() %}
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
@change="toggleSelectAll($event)"
|
||||
:checked="products.length > 0 && selectedProducts.length === products.length"
|
||||
:indeterminate="selectedProducts.length > 0 && selectedProducts.length < products.length"
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-4 py-3">Product</th>
|
||||
<th class="px-4 py-3">Identifiers</th>
|
||||
<th class="px-4 py-3">Source</th>
|
||||
<th class="px-4 py-3">Price</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<!-- Empty State -->
|
||||
<template x-if="products.length === 0">
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<span x-html="$icon('database', 'w-12 h-12 mb-2 text-gray-300')"></span>
|
||||
<p class="font-medium">No marketplace products found</p>
|
||||
<p class="text-xs mt-1" x-text="filters.search || filters.marketplace || filters.is_active || selectedVendor ? 'Try adjusting your search or filters' : 'Import products from the Import page'"></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Product Rows -->
|
||||
<template x-for="product in products" :key="product.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400" :class="isSelected(product.id) && 'bg-purple-50 dark:bg-purple-900/10'">
|
||||
<!-- Checkbox -->
|
||||
<td class="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isSelected(product.id)"
|
||||
@change="toggleSelection(product.id)"
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<!-- Product Info -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center">
|
||||
<!-- Product Image -->
|
||||
<div class="w-12 h-12 mr-3 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 flex-shrink-0">
|
||||
<template x-if="product.image_link">
|
||||
<img :src="product.image_link" :alt="product.title" class="w-full h-full object-cover" loading="lazy" />
|
||||
</template>
|
||||
<template x-if="!product.image_link">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<span x-html="$icon('photograph', 'w-6 h-6 text-gray-400')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Product Details -->
|
||||
<div class="min-w-0">
|
||||
<a :href="'/admin/marketplace-products/' + product.id" class="font-semibold text-sm truncate max-w-xs hover:text-purple-600 dark:hover:text-purple-400" x-text="product.title || 'Untitled'"></a>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="product.brand || 'No brand'"></p>
|
||||
<template x-if="product.is_digital">
|
||||
<span class="inline-flex items-center px-2 py-0.5 mt-1 text-xs font-medium text-blue-700 bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400 rounded">
|
||||
<span x-html="$icon('code', 'w-3 h-3 mr-1')"></span>
|
||||
Digital
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Identifiers -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="space-y-1">
|
||||
<template x-if="product.gtin">
|
||||
<p class="text-xs"><span class="text-gray-500">GTIN:</span> <span x-text="product.gtin" class="font-mono"></span></p>
|
||||
</template>
|
||||
<template x-if="product.sku">
|
||||
<p class="text-xs"><span class="text-gray-500">SKU:</span> <span x-text="product.sku" class="font-mono"></span></p>
|
||||
</template>
|
||||
<template x-if="!product.gtin && !product.sku">
|
||||
<p class="text-xs text-gray-400">No identifiers</p>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Source (Marketplace & Vendor) -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<p class="font-medium" x-text="product.marketplace || 'Unknown'"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[150px]" x-text="'from ' + (product.vendor_name || 'Unknown')"></p>
|
||||
</td>
|
||||
|
||||
<!-- Price -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<template x-if="product.price_numeric">
|
||||
<p class="font-medium" x-text="formatPrice(product.price_numeric, product.currency)"></p>
|
||||
</template>
|
||||
<template x-if="!product.price_numeric">
|
||||
<p class="text-gray-400">-</p>
|
||||
</template>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="product.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
|
||||
x-text="product.is_active ? 'Active' : 'Inactive'">
|
||||
</span>
|
||||
<template x-if="product.availability">
|
||||
<p class="text-xs text-gray-500 mt-1" x-text="product.availability"></p>
|
||||
</template>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<a
|
||||
:href="'/admin/marketplace-products/' + product.id"
|
||||
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-purple-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</a>
|
||||
<button
|
||||
@click="copySingleProduct(product.id)"
|
||||
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-green-600 rounded-lg dark:text-green-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Copy to Vendor Catalog"
|
||||
>
|
||||
<span x-html="$icon('duplicate', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
{{ pagination(show_condition="!loading && pagination.total > 0") }}
|
||||
</div>
|
||||
|
||||
<!-- Copy to Vendor Modal -->
|
||||
{% call modal_simple('copyToVendorModal', 'Copy to Vendor Catalog', show_var='showCopyModal', size='md') %}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Copy <span class="font-medium" x-text="selectedProducts.length"></span> selected product(s) to a vendor catalog.
|
||||
</p>
|
||||
|
||||
<!-- Target Vendor Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Target Vendor <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
x-model="copyForm.vendor_id"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">Select a vendor...</option>
|
||||
<template x-for="vendor in targetVendors" :key="vendor.id">
|
||||
<option :value="vendor.id" x-text="vendor.name + ' (' + vendor.vendor_code + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Products will be copied to this vendor's catalog
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="copyForm.skip_existing"
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Skip products that already exist in catalog</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
@click="showCopyModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="executeCopyToVendor()"
|
||||
:disabled="!copyForm.vendor_id || copying"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="copying" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="copying ? 'Copying...' : 'Copy Products'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<!-- Tom Select JS with local fallback -->
|
||||
<script>
|
||||
(function() {
|
||||
var script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/tom-select@2.4.1/dist/js/tom-select.complete.min.js';
|
||||
script.onerror = function() {
|
||||
console.warn('Tom Select CDN failed, loading local copy...');
|
||||
var fallbackScript = document.createElement('script');
|
||||
fallbackScript.src = '{{ url_for("static", path="shared/js/lib/tom-select.complete.min.js") }}';
|
||||
document.head.appendChild(fallbackScript);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
<script src="{{ url_for('marketplace_static', path='admin/js/marketplace-products.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,351 @@
|
||||
{# app/templates/admin/marketplace.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
|
||||
{% from 'shared/macros/modals.html' import job_details_modal %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %}
|
||||
{% from 'shared/macros/inputs.html' import number_stepper %}
|
||||
{% from 'shared/macros/tabs.html' import tabs_nav, tab_button, tab_panel, endtab_panel %}
|
||||
|
||||
{% block title %}Marketplace Import{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminMarketplace(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% call page_header_flex(title='Marketplace Import', subtitle='Import products from external marketplaces') %}
|
||||
{{ refresh_button(onclick='refreshJobs()') }}
|
||||
{% endcall %}
|
||||
|
||||
{{ alert_dynamic(type='success', message_var='successMessage', show_condition='successMessage') }}
|
||||
|
||||
{{ error_state('Error', show_condition='error') }}
|
||||
|
||||
<!-- Import Form Card with Tabs -->
|
||||
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Start New Import
|
||||
</h3>
|
||||
|
||||
<!-- Marketplace Tabs -->
|
||||
{% call tabs_nav(tab_var='activeImportTab') %}
|
||||
{{ tab_button('letzshop', 'Letzshop', tab_var='activeImportTab', icon='shopping-cart', onclick="switchMarketplace('letzshop')") }}
|
||||
{{ tab_button('codeswholesale', 'CodesWholesale', tab_var='activeImportTab', icon='code', onclick="switchMarketplace('codeswholesale')") }}
|
||||
{% endcall %}
|
||||
|
||||
<!-- Letzshop Import Form -->
|
||||
{{ tab_panel('letzshop', tab_var='activeImportTab') }}
|
||||
<form @submit.prevent="startImport()">
|
||||
<div class="grid gap-6 mb-4 md:grid-cols-2">
|
||||
<!-- Vendor Selection -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Vendor <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
x-model="importForm.vendor_id"
|
||||
@change="onVendorChange()"
|
||||
required
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
>
|
||||
<option value="">Select a vendor...</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.id">
|
||||
<option :value="vendor.id" x-text="`${vendor.name} (${vendor.vendor_code})`"></option>
|
||||
</template>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Select the vendor to import products for
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- CSV URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
CSV URL <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
x-model="importForm.csv_url"
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com/products.csv"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Enter the URL of the Letzshop CSV feed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Language Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Language
|
||||
</label>
|
||||
<select
|
||||
x-model="importForm.language"
|
||||
@change="onLanguageChange()"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
>
|
||||
<option value="fr">French (FR)</option>
|
||||
<option value="en">English (EN)</option>
|
||||
<option value="de">German (DE)</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Select the language of the CSV feed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Batch Size -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Batch Size
|
||||
</label>
|
||||
{{ number_stepper(model='importForm.batch_size', min=100, max=5000, step=100, label='Batch Size') }}
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Number of products to process per batch (100-5000)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Fill Buttons (when vendor is selected) -->
|
||||
<div class="mb-4" x-show="importForm.vendor_id && selectedVendor">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Quick Fill (from vendor settings)
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('fr')"
|
||||
x-show="selectedVendor?.letzshop_csv_url_fr"
|
||||
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
French CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('en')"
|
||||
x-show="selectedVendor?.letzshop_csv_url_en"
|
||||
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
English CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('de')"
|
||||
x-show="selectedVendor?.letzshop_csv_url_de"
|
||||
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
German CSV
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-red-600 dark:text-red-400" x-show="!selectedVendor?.letzshop_csv_url_fr && !selectedVendor?.letzshop_csv_url_en && !selectedVendor?.letzshop_csv_url_de">
|
||||
This vendor has no Letzshop CSV URLs configured
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="importing || !importForm.csv_url || !importForm.vendor_id"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!importing" x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="importing ? 'Starting Import...' : 'Start Import'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ endtab_panel() }}
|
||||
|
||||
<!-- CodesWholesale Import Form -->
|
||||
{{ tab_panel('codeswholesale', tab_var='activeImportTab') }}
|
||||
<div class="text-center py-12">
|
||||
<span x-html="$icon('code', 'inline w-16 h-16 text-gray-300 dark:text-gray-600 mb-4')"></span>
|
||||
<h4 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
CodesWholesale Integration
|
||||
</h4>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">
|
||||
Import digital game keys and software licenses from CodesWholesale API.
|
||||
</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">
|
||||
Coming soon - This feature is under development
|
||||
</p>
|
||||
</div>
|
||||
{{ endtab_panel() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 p-4">
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Vendor
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.vendor_id"
|
||||
@change="loadJobs()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Vendors</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.id">
|
||||
<option :value="vendor.id" x-text="`${vendor.name} (${vendor.vendor_code})`"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Status
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.status"
|
||||
@change="loadJobs()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="completed_with_errors">Completed with Errors</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Marketplace
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.marketplace"
|
||||
@change="loadJobs()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Marketplaces</option>
|
||||
<option value="Letzshop">Letzshop</option>
|
||||
<option value="CodesWholesale">CodesWholesale</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="clearFilters()"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Jobs List -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
My Import Jobs
|
||||
</h3>
|
||||
<a href="/admin/imports" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300">
|
||||
View all system imports →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading import jobs...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && jobs.length === 0" class="text-center py-12">
|
||||
<span x-html="$icon('inbox', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-gray-600 dark:text-gray-400">You haven't triggered any imports yet</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500">Start a new import using the form above</p>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Table -->
|
||||
<div x-show="!loading && jobs.length > 0">
|
||||
{% call table_wrapper() %}
|
||||
{{ table_header(['Job ID', 'Vendor', 'Marketplace', 'Status', 'Progress', 'Started', 'Duration', 'Actions']) }}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="job in jobs" :key="job.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
#<span x-text="job.id"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="getVendorName(job.vendor_id)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.marketplace"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
|
||||
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
|
||||
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
|
||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors'
|
||||
}"
|
||||
x-text="job.status.replace('_', ' ').toUpperCase()">
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span class="text-green-600 dark:text-green-400" x-text="job.imported_count"></span> imported,
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="job.updated_count"></span> updated
|
||||
</div>
|
||||
<div x-show="job.error_count > 0" class="text-xs text-red-600 dark:text-red-400">
|
||||
<span x-text="job.error_count"></span> errors
|
||||
</div>
|
||||
<div x-show="job.total_processed > 0" class="text-xs text-gray-500 dark:text-gray-500">
|
||||
Total: <span x-text="job.total_processed"></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.started_at ? formatDate(job.started_at) : 'Not started'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="calculateDuration(job)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="viewJobDetails(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="job.status === 'processing' || job.status === 'pending'"
|
||||
@click="refreshJobStatus(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Refresh Status"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
{{ pagination(show_condition="!loading && pagination.total > 0") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ job_details_modal() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('marketplace_static', path='admin/js/marketplace.js') }}?v=2"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,210 @@
|
||||
{# app/modules/marketplace/templates/marketplace/admin/partials/letzshop-exceptions-tab.html #}
|
||||
{# Exceptions tab for admin Letzshop management - Order Item Exception Resolution #}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Product Exceptions</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedVendor ? 'Resolve unmatched products from order imports' : 'All exceptions across vendors'"></p>
|
||||
</div>
|
||||
<button
|
||||
@click="loadExceptions()"
|
||||
:disabled="loadingExceptions"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span x-show="!loadingExceptions" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="loadingExceptions" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-4">
|
||||
<!-- Pending Exceptions -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:bg-orange-900">
|
||||
<span x-html="$icon('exclamation-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Pending</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="exceptionStats.pending || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolved Exceptions -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:bg-green-900">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Resolved</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="exceptionStats.resolved || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ignored Exceptions -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-gray-500 bg-gray-100 rounded-full dark:bg-gray-700">
|
||||
<span x-html="$icon('ban', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Ignored</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="exceptionStats.ignored || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders Affected -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:bg-purple-900">
|
||||
<span x-html="$icon('shopping-cart', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Orders Affected</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="exceptionStats.orders_with_exceptions || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 flex flex-wrap gap-4 items-center">
|
||||
<!-- Search input -->
|
||||
<div class="relative flex-1 min-w-[200px] max-w-md">
|
||||
<span x-html="$icon('search', 'w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400')"></span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="exceptionsSearch"
|
||||
@input.debounce.300ms="pagination.page = 1; loadExceptions()"
|
||||
placeholder="Search by GTIN, product name, or order..."
|
||||
class="w-full pl-9 pr-8 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
x-show="exceptionsSearch"
|
||||
@click="exceptionsSearch = ''; pagination.page = 1; loadExceptions()"
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status filter -->
|
||||
<select
|
||||
x-model="exceptionsFilter"
|
||||
@change="pagination.page = 1; loadExceptions()"
|
||||
class="px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="ignored">Ignored</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Exceptions Table -->
|
||||
<div class="w-full overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Product Info</th>
|
||||
<th x-show="!selectedVendor" class="px-4 py-3">Vendor</th>
|
||||
<th class="px-4 py-3">GTIN</th>
|
||||
<th class="px-4 py-3">Order</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Created</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="loadingExceptions && exceptions.length === 0">
|
||||
<tr>
|
||||
<td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
|
||||
<p>Loading exceptions...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-if="!loadingExceptions && exceptions.length === 0">
|
||||
<tr>
|
||||
<td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('check-circle', 'w-12 h-12 mx-auto mb-2 text-green-300')"></span>
|
||||
<p class="font-medium">No exceptions found</p>
|
||||
<p class="text-sm mt-1">All order items are properly matched to products</p>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="exc in exceptions" :key="exc.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<div>
|
||||
<p class="font-semibold text-gray-700 dark:text-gray-200" x-text="exc.original_product_name || 'Unknown Product'"></p>
|
||||
<p class="text-xs text-gray-500" x-show="exc.original_sku" x-text="'SKU: ' + exc.original_sku"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Vendor column (only in cross-vendor view) -->
|
||||
<td x-show="!selectedVendor" class="px-4 py-3 text-sm">
|
||||
<p class="font-medium text-gray-700 dark:text-gray-200" x-text="exc.vendor_name || 'N/A'"></p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<code class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 rounded" x-text="exc.original_gtin || 'No GTIN'"></code>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<a
|
||||
:href="'/admin/orders/' + exc.order_id"
|
||||
class="text-purple-600 hover:text-purple-800 dark:text-purple-400"
|
||||
x-text="exc.order_number"
|
||||
></a>
|
||||
<p class="text-xs text-gray-500" x-text="formatDate(exc.order_date)"></p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span
|
||||
class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="{
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': exc.status === 'pending',
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': exc.status === 'resolved',
|
||||
'text-gray-700 bg-gray-100 dark:bg-gray-600 dark:text-gray-100': exc.status === 'ignored'
|
||||
}"
|
||||
x-text="exc.status.toUpperCase()"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="formatDate(exc.created_at)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<template x-if="exc.status === 'pending'">
|
||||
<button
|
||||
@click="openResolveModal(exc)"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-green-600 transition-colors duration-150 rounded-md hover:bg-green-100 dark:hover:bg-green-900"
|
||||
title="Resolve - Assign Product"
|
||||
>
|
||||
<span x-html="$icon('check', 'w-4 h-4 mr-1')"></span>
|
||||
Resolve
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="exc.status === 'pending'">
|
||||
<button
|
||||
@click="ignoreException(exc)"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-gray-600 transition-colors duration-150 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Ignore Exception"
|
||||
>
|
||||
<span x-html="$icon('ban', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="exc.status === 'resolved'">
|
||||
<span class="text-xs text-gray-500">
|
||||
<span x-html="$icon('check', 'w-3 h-3 inline mr-1')"></span>
|
||||
Resolved
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ pagination(show_condition="!loadingExceptions && pagination.total > 0") }}
|
||||
</div>
|
||||
@@ -0,0 +1,326 @@
|
||||
{# app/modules/marketplace/templates/marketplace/admin/partials/letzshop-jobs-table.html #}
|
||||
{# Unified jobs table for admin Letzshop management - Import, Export, and Sync jobs #}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Recent Jobs</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span x-show="selectedVendor">Product imports, exports, and order sync history</span>
|
||||
<span x-show="!selectedVendor">All Letzshop jobs across all vendors</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="loadJobs()"
|
||||
:disabled="loadingJobs"
|
||||
class="flex items-center px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!loadingJobs" x-html="$icon('refresh', 'w-4 h-4 mr-1')"></span>
|
||||
<span x-show="loadingJobs" x-html="$icon('spinner', 'w-4 h-4 mr-1')"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 flex flex-wrap gap-3">
|
||||
<select
|
||||
x-model="jobsFilter.type"
|
||||
@change="loadJobs()"
|
||||
class="px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="import">Product Import</option>
|
||||
<option value="export">Product Export</option>
|
||||
<option value="historical_import">Historical Order Import</option>
|
||||
<option value="order_sync">Order Sync</option>
|
||||
</select>
|
||||
<select
|
||||
x-model="jobsFilter.status"
|
||||
@change="loadJobs()"
|
||||
class="px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="completed_with_errors">Completed with Errors</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Table -->
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-700">
|
||||
<th class="px-4 py-3">ID</th>
|
||||
<th class="px-4 py-3">Vendor</th>
|
||||
<th class="px-4 py-3">Type</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Records</th>
|
||||
<th class="px-4 py-3">Started</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="loadingJobs && jobs.length === 0">
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
|
||||
<p>Loading jobs...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-if="!loadingJobs && jobs.length === 0">
|
||||
<tr>
|
||||
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('collection', 'w-12 h-12 mx-auto mb-2 text-gray-300')"></span>
|
||||
<p class="font-medium">No jobs found</p>
|
||||
<p class="text-sm mt-1">Import products or sync orders to see job history</p>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="job in jobs" :key="job.id + '-' + job.type">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3 text-sm font-medium">
|
||||
<span x-text="'#' + job.id"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.vendor_code || job.vendor_name || '-'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
:class="{
|
||||
'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300': job.type === 'import',
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300': job.type === 'export',
|
||||
'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300': job.type === 'historical_import',
|
||||
'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300': job.type === 'order_sync'
|
||||
}"
|
||||
>
|
||||
<span x-show="job.type === 'import'" x-html="$icon('cloud-download', 'inline w-3 h-3 mr-1')"></span>
|
||||
<span x-show="job.type === 'export'" x-html="$icon('cloud-upload', 'inline w-3 h-3 mr-1')"></span>
|
||||
<span x-show="job.type === 'historical_import'" x-html="$icon('clock', 'inline w-3 h-3 mr-1')"></span>
|
||||
<span x-show="job.type === 'order_sync'" x-html="$icon('refresh', 'inline w-3 h-3 mr-1')"></span>
|
||||
<span x-text="job.type === 'import' ? 'Product Import' : job.type === 'export' ? 'Product Export' : job.type === 'historical_import' ? 'Historical Import' : 'Order Sync'"></span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-semibold leading-tight rounded-full"
|
||||
:class="{
|
||||
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-300': job.status === 'pending',
|
||||
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed' || job.status === 'success',
|
||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors' || job.status === 'partial'
|
||||
}"
|
||||
x-text="job.status.replace(/_/g, ' ').toUpperCase()"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-green-600 dark:text-green-400" x-text="job.records_succeeded || 0"></span>
|
||||
<span class="text-gray-400">/</span>
|
||||
<span x-text="job.records_processed || 0"></span>
|
||||
<span x-show="job.records_failed > 0" class="text-red-600 dark:text-red-400">
|
||||
(<span x-text="job.records_failed"></span> failed)
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="formatDate(job.started_at || job.created_at)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="formatDuration(job.started_at, job.completed_at)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
x-show="(job.type === 'import' || job.type === 'historical_import') && (job.status === 'failed' || job.status === 'completed_with_errors')"
|
||||
@click="viewJobErrors(job)"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-red-600 transition-colors duration-150 rounded-md hover:bg-red-100 dark:hover:bg-red-900"
|
||||
title="View Errors"
|
||||
>
|
||||
<span x-html="$icon('exclamation-circle', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="viewJobDetails(job)"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-gray-600 transition-colors duration-150 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{ pagination(show_condition="!loadingJobs && pagination.total > 0") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Details Modal -->
|
||||
<div
|
||||
x-show="showJobDetailsModal"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-30 flex items-center justify-center bg-black bg-opacity-50"
|
||||
@click.self="showJobDetailsModal = false"
|
||||
x-cloak
|
||||
>
|
||||
<div
|
||||
x-show="showJobDetailsModal"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 transform scale-100"
|
||||
x-transition:leave-end="opacity-0 transform scale-95"
|
||||
class="w-full max-w-lg bg-white dark:bg-gray-800 rounded-lg shadow-xl"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="flex justify-between items-center px-6 py-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Job Details</h3>
|
||||
<button
|
||||
@click="showJobDetailsModal = false"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<span x-html="$icon('close', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<!-- Job Info Grid -->
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-gray-600 dark:text-gray-400">Job ID:</span>
|
||||
<span class="ml-2 text-gray-900 dark:text-gray-100">#<span x-text="selectedJobDetails?.id"></span></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-600 dark:text-gray-400">Type:</span>
|
||||
<span class="ml-2">
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="{
|
||||
'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300': selectedJobDetails?.type === 'import',
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300': selectedJobDetails?.type === 'export',
|
||||
'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300': selectedJobDetails?.type === 'historical_import',
|
||||
'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300': selectedJobDetails?.type === 'order_sync'
|
||||
}"
|
||||
x-text="selectedJobDetails?.type === 'import' ? 'Product Import' : selectedJobDetails?.type === 'export' ? 'Product Export' : selectedJobDetails?.type === 'historical_import' ? 'Historical Import' : 'Order Sync'"
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-600 dark:text-gray-400">Status:</span>
|
||||
<span class="ml-2">
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-semibold rounded-full"
|
||||
:class="{
|
||||
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-300': selectedJobDetails?.status === 'pending',
|
||||
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': selectedJobDetails?.status === 'processing',
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': selectedJobDetails?.status === 'completed' || selectedJobDetails?.status === 'success',
|
||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': selectedJobDetails?.status === 'failed',
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': selectedJobDetails?.status === 'completed_with_errors' || selectedJobDetails?.status === 'partial'
|
||||
}"
|
||||
x-text="selectedJobDetails?.status?.replace(/_/g, ' ').toUpperCase()"
|
||||
></span>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-600 dark:text-gray-400">Vendor:</span>
|
||||
<span class="ml-2 text-gray-900 dark:text-gray-100" x-text="selectedJobDetails?.vendor_code || selectedJobDetails?.vendor_name || selectedVendor?.name || '-'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Records Info -->
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-2">Records</h4>
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-green-600 dark:text-green-400" x-text="selectedJobDetails?.records_succeeded || 0"></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Succeeded</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-gray-600 dark:text-gray-300" x-text="selectedJobDetails?.records_processed || 0"></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Processed</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-red-600 dark:text-red-400" x-text="selectedJobDetails?.records_failed || 0"></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timestamps -->
|
||||
<div class="text-sm space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium text-gray-600 dark:text-gray-400">Started:</span>
|
||||
<span class="text-gray-900 dark:text-gray-100" x-text="formatDate(selectedJobDetails?.started_at || selectedJobDetails?.created_at)"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium text-gray-600 dark:text-gray-400">Completed:</span>
|
||||
<span class="text-gray-900 dark:text-gray-100" x-text="selectedJobDetails?.completed_at ? formatDate(selectedJobDetails?.completed_at) : 'In progress...'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium text-gray-600 dark:text-gray-400">Duration:</span>
|
||||
<span class="text-gray-900 dark:text-gray-100" x-text="formatDuration(selectedJobDetails?.started_at || selectedJobDetails?.created_at, selectedJobDetails?.completed_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Details (for export jobs) -->
|
||||
<template x-if="selectedJobDetails?.type === 'export' && selectedJobDetails?.error_details">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<h4 class="font-medium text-blue-700 dark:text-blue-300 mb-2">Export Details</h4>
|
||||
<p class="text-sm text-blue-600 dark:text-blue-400 mb-2">
|
||||
Products exported: <span class="font-medium" x-text="selectedJobDetails?.error_details?.products_exported || 0"></span>
|
||||
</p>
|
||||
<template x-if="selectedJobDetails?.error_details?.files">
|
||||
<div class="space-y-1">
|
||||
<template x-for="file in selectedJobDetails.error_details.files" :key="file.language">
|
||||
<div class="text-xs flex justify-between items-center py-1 border-b border-blue-100 dark:border-blue-800 last:border-0">
|
||||
<span class="font-medium text-blue-700 dark:text-blue-300" x-text="file.language?.toUpperCase()"></span>
|
||||
<span x-show="file.error" class="text-red-600 dark:text-red-400" x-text="'Failed: ' + file.error"></span>
|
||||
<span x-show="!file.error" class="text-blue-600 dark:text-blue-400">
|
||||
<span x-text="file.filename"></span>
|
||||
<span class="text-gray-400 ml-1">(<span x-text="(file.size_bytes / 1024).toFixed(1)"></span> KB)</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error Details -->
|
||||
<template x-if="selectedJobDetails?.error_message || (selectedJobDetails?.error_details?.error && selectedJobDetails?.type !== 'export')">
|
||||
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
|
||||
<h4 class="font-medium text-red-700 dark:text-red-300 mb-2">Error</h4>
|
||||
<p class="text-sm text-red-600 dark:text-red-400" x-text="selectedJobDetails?.error_message || selectedJobDetails?.error_details?.error"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="px-6 py-4 border-t dark:border-gray-700 flex justify-end">
|
||||
<button
|
||||
@click="showJobDetailsModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,307 @@
|
||||
{# app/modules/marketplace/templates/marketplace/admin/partials/letzshop-orders-tab.html #}
|
||||
{# Orders tab for admin Letzshop management #}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
|
||||
<!-- Header with Import Buttons -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Orders</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedVendor ? 'Manage Letzshop orders for this vendor' : 'All Letzshop orders across vendors'"></p>
|
||||
</div>
|
||||
<!-- Import buttons only shown when vendor is selected -->
|
||||
<div x-show="selectedVendor" class="flex gap-2">
|
||||
<button
|
||||
@click="importHistoricalOrders()"
|
||||
:disabled="!letzshopStatus.is_configured || importingHistorical"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Import all historical confirmed and declined orders"
|
||||
>
|
||||
<span x-show="!importingHistorical" x-html="$icon('archive', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="importingHistorical" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="!importingHistorical">Import History</span>
|
||||
<span x-show="importingHistorical" x-text="historicalImportProgress?.message || 'Starting...'"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="importOrders()"
|
||||
:disabled="!letzshopStatus.is_configured || importingOrders"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!importingOrders" x-html="$icon('download', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="importingOrders" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="importingOrders ? 'Importing...' : 'Import New'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Historical Import Progress -->
|
||||
<div x-show="historicalImportProgress && importingHistorical" x-transition class="mb-6 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('spinner', 'w-5 h-5 text-purple-500 mr-3')"></span>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-purple-800 dark:text-purple-200">Historical Import in Progress</h4>
|
||||
<p class="text-sm text-purple-700 dark:text-purple-300 mt-1" x-text="historicalImportProgress?.message"></p>
|
||||
<div class="flex gap-4 mt-2 text-xs text-purple-600 dark:text-purple-400">
|
||||
<span x-show="historicalImportProgress?.current_phase">
|
||||
Phase: <strong x-text="historicalImportProgress?.current_phase"></strong>
|
||||
</span>
|
||||
<span x-show="historicalImportProgress?.shipments_fetched > 0">
|
||||
Fetched: <strong x-text="historicalImportProgress?.shipments_fetched"></strong>
|
||||
</span>
|
||||
<span x-show="historicalImportProgress?.orders_processed > 0">
|
||||
Processed: <strong x-text="historicalImportProgress?.orders_processed"></strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Historical Import Result -->
|
||||
<div x-show="historicalImportResult" x-transition class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 text-blue-500 mr-3 mt-0.5')"></span>
|
||||
<div>
|
||||
<h4 class="font-medium text-blue-800 dark:text-blue-200">Historical Import Complete</h4>
|
||||
<div class="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
<span x-text="historicalImportResult?.imported + ' imported'"></span> ·
|
||||
<span x-text="historicalImportResult?.updated + ' updated'"></span> ·
|
||||
<span x-text="historicalImportResult?.skipped + ' skipped'"></span>
|
||||
</div>
|
||||
<div x-show="historicalImportResult?.products_matched > 0 || historicalImportResult?.products_not_found > 0" class="text-sm text-blue-600 dark:text-blue-400 mt-1">
|
||||
<span x-text="historicalImportResult?.products_matched + ' products matched by EAN'"></span> ·
|
||||
<span x-text="historicalImportResult?.products_not_found + ' not found'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="historicalImportResult = null" class="text-blue-500 hover:text-blue-700">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Cards -->
|
||||
<div class="grid gap-6 mb-8" :class="selectedVendor ? 'md:grid-cols-5' : 'md:grid-cols-4'">
|
||||
<!-- Connection Status (only when vendor selected) -->
|
||||
<div x-show="selectedVendor" class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div :class="letzshopStatus.is_configured ? 'bg-green-100 dark:bg-green-900' : 'bg-gray-100 dark:bg-gray-700'" class="p-3 mr-4 rounded-full">
|
||||
<span x-html="$icon(letzshopStatus.is_configured ? 'check' : 'x', letzshopStatus.is_configured ? 'w-5 h-5 text-green-500' : 'w-5 h-5 text-gray-400')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Connection</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="letzshopStatus.is_configured ? 'Configured' : 'Not Configured'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Orders -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:bg-orange-900">
|
||||
<span x-html="$icon('clock', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Pending</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="orderStats.pending"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmed/Processing Orders -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:bg-green-900">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Confirmed</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="orderStats.processing"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Declined/Cancelled Orders -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:bg-red-900">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Declined</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="orderStats.cancelled"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipped Orders -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:bg-blue-900">
|
||||
<span x-html="$icon('truck', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Shipped</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="orderStats.shipped"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 flex flex-wrap gap-4 items-center">
|
||||
<!-- Search input -->
|
||||
<div class="relative flex-1 min-w-[200px] max-w-md">
|
||||
<span x-html="$icon('search', 'w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400')"></span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="ordersSearch"
|
||||
@input.debounce.300ms="pagination.page = 1; loadOrders()"
|
||||
placeholder="Search by order #, name, or email..."
|
||||
class="w-full pl-9 pr-8 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
x-show="ordersSearch"
|
||||
@click="ordersSearch = ''; pagination.page = 1; loadOrders()"
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status filter -->
|
||||
<select
|
||||
x-model="ordersFilter"
|
||||
@change="ordersHasDeclinedItems = false; pagination.page = 1; loadOrders()"
|
||||
class="px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processing">Confirmed</option>
|
||||
<option value="cancelled">Declined</option>
|
||||
<option value="shipped">Shipped</option>
|
||||
</select>
|
||||
|
||||
<!-- Declined items filter -->
|
||||
<button
|
||||
@click="ordersFilter = ''; ordersHasDeclinedItems = !ordersHasDeclinedItems; pagination.page = 1; loadOrders()"
|
||||
:class="ordersHasDeclinedItems ? 'bg-red-100 dark:bg-red-900 border-red-300 dark:border-red-700 text-red-700 dark:text-red-300' : 'bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
:title="ordersHasDeclinedItems ? 'Showing orders with declined items' : 'Show only orders with declined items'"
|
||||
>
|
||||
<span x-html="$icon('x-circle', 'w-4 h-4 inline mr-1')"></span>
|
||||
Has Declined Items
|
||||
<span x-show="orderStats.has_declined_items > 0" class="ml-1 px-1.5 py-0.5 text-xs bg-red-200 dark:bg-red-800 rounded-full" x-text="orderStats.has_declined_items"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Not Configured Warning (only when vendor selected) -->
|
||||
<div x-show="selectedVendor && !letzshopStatus.is_configured" class="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 text-yellow-500 mr-3')"></span>
|
||||
<div>
|
||||
<h4 class="font-medium text-yellow-800 dark:text-yellow-200">API Not Configured</h4>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300">Configure the Letzshop API key in the Settings tab to import and manage orders.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders Table -->
|
||||
<div class="w-full overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Order</th>
|
||||
<th x-show="!selectedVendor" class="px-4 py-3">Vendor</th>
|
||||
<th class="px-4 py-3">Customer</th>
|
||||
<th class="px-4 py-3">Total</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Date</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="loadingOrders && orders.length === 0">
|
||||
<tr>
|
||||
<td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
|
||||
<p>Loading orders...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-if="!loadingOrders && orders.length === 0">
|
||||
<tr>
|
||||
<td :colspan="selectedVendor ? 6 : 7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('inbox', 'w-12 h-12 mx-auto mb-2 text-gray-300')"></span>
|
||||
<p class="font-medium">No orders found</p>
|
||||
<p class="text-sm mt-1" x-text="selectedVendor ? 'Click Import Orders to fetch orders from Letzshop' : 'Select a vendor to import orders'"></p>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="order in orders" :key="order.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<div>
|
||||
<p class="font-semibold" x-text="order.external_order_number || order.order_number"></p>
|
||||
<p class="text-xs text-gray-500" x-text="'#' + order.id"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Vendor column (only in cross-vendor view) -->
|
||||
<td x-show="!selectedVendor" class="px-4 py-3 text-sm">
|
||||
<p class="font-medium text-gray-700 dark:text-gray-200" x-text="order.vendor_name || 'N/A'"></p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<p x-text="order.customer_email || 'N/A'"></p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="order.total_amount ? order.total_amount + ' ' + order.currency : 'N/A'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span
|
||||
class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
:class="{
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': order.status === 'pending',
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': order.status === 'processing',
|
||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': order.status === 'cancelled',
|
||||
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': order.status === 'shipped'
|
||||
}"
|
||||
x-text="order.status === 'cancelled' ? 'DECLINED' : (order.status === 'processing' ? 'CONFIRMED' : order.status.toUpperCase())"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="formatDate(order.order_date || order.created_at)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2 text-sm">
|
||||
<button
|
||||
x-show="order.status === 'pending'"
|
||||
@click="confirmOrder(order)"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-green-600 transition-colors duration-150 rounded-md hover:bg-green-100 dark:hover:bg-green-900"
|
||||
title="Confirm Order"
|
||||
>
|
||||
<span x-html="$icon('check', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="order.status === 'pending'"
|
||||
@click="declineOrder(order)"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-red-600 transition-colors duration-150 rounded-md hover:bg-red-100 dark:hover:bg-red-900"
|
||||
title="Decline Order"
|
||||
>
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="order.status === 'processing'"
|
||||
@click="openTrackingModal(order)"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-blue-600 transition-colors duration-150 rounded-md hover:bg-blue-100 dark:hover:bg-blue-900"
|
||||
title="Set Tracking"
|
||||
>
|
||||
<span x-html="$icon('truck', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="viewOrderDetails(order)"
|
||||
class="flex items-center justify-center px-2 py-1 text-sm text-gray-600 transition-colors duration-150 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ pagination(show_condition="!loadingOrders && pagination.total > 0") }}
|
||||
</div>
|
||||
@@ -0,0 +1,362 @@
|
||||
{# app/modules/marketplace/templates/marketplace/admin/partials/letzshop-products-tab.html #}
|
||||
{# Products tab for admin Letzshop management - Product listing with Import/Export #}
|
||||
{% from 'shared/macros/pagination.html' import pagination %}
|
||||
{% from 'shared/macros/tables.html' import table_wrapper %}
|
||||
|
||||
<!-- Header with Import/Export Buttons -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Letzshop Products</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span x-show="selectedVendor" x-text="'Products from ' + (selectedVendor?.name || '')"></span>
|
||||
<span x-show="!selectedVendor">All Letzshop marketplace products</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3" x-show="selectedVendor">
|
||||
<!-- Import Button (only when vendor selected) -->
|
||||
<button
|
||||
@click="showImportModal = true"
|
||||
:disabled="importing"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!importing" x-html="$icon('cloud-download', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="importing ? 'Importing...' : 'Import'"></span>
|
||||
</button>
|
||||
<!-- Export Button (only when vendor selected) -->
|
||||
<button
|
||||
@click="exportAllLanguages()"
|
||||
:disabled="exporting"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!exporting" x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="exporting" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="exporting ? 'Exporting...' : 'Export'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-4 mb-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Total Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('cube', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Total Products</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="productStats.total || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Active</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="productStats.active || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inactive Products -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Inactive</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="productStats.inactive || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Sync -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('refresh', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Last Sync</p>
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="productStats.last_sync || 'Never'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters Bar -->
|
||||
<div class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<!-- Search Input -->
|
||||
<div class="flex-1 max-w-xl">
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="productFilters.search"
|
||||
@input.debounce.300ms="loadProducts()"
|
||||
placeholder="Search by title, GTIN, or SKU..."
|
||||
class="w-full pl-10 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Status Filter -->
|
||||
<select
|
||||
x-model="productFilters.is_active"
|
||||
@change="pagination.page = 1; loadProducts()"
|
||||
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<button
|
||||
@click="loadProducts()"
|
||||
:disabled="loadingProducts"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none transition-colors"
|
||||
title="Refresh products"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loadingProducts" class="flex items-center justify-center py-12">
|
||||
<span x-html="$icon('spinner', 'w-8 h-8 text-purple-600')"></span>
|
||||
<span class="ml-3 text-gray-600 dark:text-gray-400">Loading products...</span>
|
||||
</div>
|
||||
|
||||
<!-- Products Table -->
|
||||
<div x-show="!loadingProducts">
|
||||
{% call table_wrapper() %}
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Product</th>
|
||||
<th class="px-4 py-3" x-show="!selectedVendor">Vendor</th>
|
||||
<th class="px-4 py-3">Identifiers</th>
|
||||
<th class="px-4 py-3">Price</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<!-- Empty State -->
|
||||
<template x-if="products.length === 0">
|
||||
<tr>
|
||||
<td :colspan="selectedVendor ? 5 : 6" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center">
|
||||
<span x-html="$icon('cube', 'w-12 h-12 mb-2 text-gray-300')"></span>
|
||||
<p class="font-medium">No products found</p>
|
||||
<p class="text-xs mt-1" x-text="productFilters.search ? 'Try adjusting your search' : (selectedVendor ? 'Import products to get started' : 'No Letzshop products in the catalog')"></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<!-- Product Rows -->
|
||||
<template x-for="product in products" :key="product.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<!-- Product Info -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center">
|
||||
<!-- Product Image -->
|
||||
<div class="w-10 h-10 mr-3 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 flex-shrink-0">
|
||||
<template x-if="product.image_link">
|
||||
<img :src="product.image_link" :alt="product.title" class="w-full h-full object-cover" loading="lazy" />
|
||||
</template>
|
||||
<template x-if="!product.image_link">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<span x-html="$icon('photograph', 'w-5 h-5 text-gray-400')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Product Details -->
|
||||
<div class="min-w-0">
|
||||
<a :href="'/admin/letzshop/products/' + product.id" class="font-semibold text-sm truncate max-w-xs hover:text-purple-600 dark:hover:text-purple-400" x-text="product.title || 'Untitled'"></a>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400" x-text="product.brand || 'No brand'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Vendor (shown when no vendor filter) -->
|
||||
<td class="px-4 py-3 text-sm" x-show="!selectedVendor">
|
||||
<span class="font-medium" x-text="product.vendor_name || '-'"></span>
|
||||
</td>
|
||||
|
||||
<!-- Identifiers -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="space-y-1">
|
||||
<template x-if="product.gtin">
|
||||
<p class="text-xs"><span class="text-gray-500">GTIN:</span> <span x-text="product.gtin" class="font-mono"></span></p>
|
||||
</template>
|
||||
<template x-if="product.sku">
|
||||
<p class="text-xs"><span class="text-gray-500">SKU:</span> <span x-text="product.sku" class="font-mono"></span></p>
|
||||
</template>
|
||||
<template x-if="!product.gtin && !product.sku">
|
||||
<p class="text-xs text-gray-400">No identifiers</p>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Price -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<template x-if="product.price_numeric">
|
||||
<p class="font-medium" x-text="formatPrice(product.price_numeric, product.currency || 'EUR')"></p>
|
||||
</template>
|
||||
<template x-if="!product.price_numeric">
|
||||
<p class="text-gray-400">-</p>
|
||||
</template>
|
||||
</td>
|
||||
|
||||
<!-- Status -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="product.is_active ? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100' : 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
|
||||
x-text="product.is_active ? 'Active' : 'Inactive'">
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<a
|
||||
:href="'/admin/letzshop/products/' + product.id"
|
||||
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-purple-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
{% endcall %}
|
||||
|
||||
{{ pagination(show_condition="!loadingProducts && pagination.total > 0") }}
|
||||
</div>
|
||||
|
||||
<!-- Import Modal -->
|
||||
<div
|
||||
x-show="showImportModal"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
@click.self="showImportModal = false"
|
||||
x-cloak
|
||||
>
|
||||
<div
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 transform translate-y-1/2"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0 transform translate-y-1/2"
|
||||
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-lg"
|
||||
@click.stop
|
||||
>
|
||||
<header class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Import Products from Letzshop</h3>
|
||||
<button @click="showImportModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Import products from Letzshop CSV feeds. All languages will be imported.
|
||||
</p>
|
||||
|
||||
<!-- Quick Fill Buttons -->
|
||||
<div class="mb-4" x-show="selectedVendor?.letzshop_csv_url_fr || selectedVendor?.letzshop_csv_url_en || selectedVendor?.letzshop_csv_url_de">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Quick Import
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="startImportAllLanguages()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<span x-html="$icon('cloud-download', 'w-4 h-4 mr-2')"></span>
|
||||
Import All Languages
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Imports products from all configured CSV URLs (FR, EN, DE)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-600 pt-4 mt-4">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Or import from custom URL:</p>
|
||||
<form @submit.prevent="startImportFromUrl()">
|
||||
<!-- CSV URL -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
CSV URL
|
||||
</label>
|
||||
<input
|
||||
x-model="importForm.csv_url"
|
||||
type="url"
|
||||
placeholder="https://letzshop.lu/feeds/products.csv"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Language Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Language for this URL
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" @click="importForm.language = 'fr'"
|
||||
:class="importForm.language === 'fr' ? 'bg-purple-100 border-purple-500 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium border rounded-lg">
|
||||
<span class="fi fi-fr"></span> FR
|
||||
</button>
|
||||
<button type="button" @click="importForm.language = 'de'"
|
||||
:class="importForm.language === 'de' ? 'bg-purple-100 border-purple-500 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium border rounded-lg">
|
||||
<span class="fi fi-de"></span> DE
|
||||
</button>
|
||||
<button type="button" @click="importForm.language = 'en'"
|
||||
:class="importForm.language === 'en' ? 'bg-purple-100 border-purple-500 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300'"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium border rounded-lg">
|
||||
<span class="fi fi-gb"></span> EN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="showImportModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="importing || !importForm.csv_url"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,410 @@
|
||||
{# app/modules/marketplace/templates/marketplace/admin/partials/letzshop-settings-tab.html #}
|
||||
{# Settings tab for admin Letzshop management - API credentials, CSV URLs, Import/Export settings #}
|
||||
{% from 'shared/macros/inputs.html' import number_stepper %}
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- API Configuration Card -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Letzshop API Configuration
|
||||
</h3>
|
||||
|
||||
<form @submit.prevent="saveCredentials()">
|
||||
<!-- API Key -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
API Key <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
:type="showApiKey ? 'text' : 'password'"
|
||||
x-model="settingsForm.api_key"
|
||||
:placeholder="credentials ? credentials.api_key_masked : 'Enter Letzshop API key'"
|
||||
class="block w-full px-3 py-2 pr-10 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showApiKey = !showApiKey"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<span x-html="$icon(showApiKey ? 'eye-off' : 'eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Get your API key from the Letzshop merchant portal
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Test Mode -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="settingsForm.test_mode_enabled"
|
||||
class="form-checkbox h-5 w-5 text-orange-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:ring-orange-500"
|
||||
/>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Test Mode</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8">
|
||||
When enabled, operations (confirm, reject, tracking) will NOT be sent to Letzshop API
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Test Mode Warning -->
|
||||
<div x-show="settingsForm.test_mode_enabled" class="mb-4 p-3 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 text-orange-500 mr-2')"></span>
|
||||
<span class="text-sm text-orange-700 dark:text-orange-300 font-medium">Test Mode Active</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-orange-600 dark:text-orange-400 ml-7">
|
||||
All Letzshop API mutations are disabled. Orders can be imported but confirmations/rejections will only be saved locally.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Auto Sync -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="settingsForm.auto_sync_enabled"
|
||||
class="form-checkbox h-5 w-5 text-purple-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Enable Auto-Sync</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8">
|
||||
Automatically import new orders periodically
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sync Interval -->
|
||||
<div class="mb-6" x-show="settingsForm.auto_sync_enabled">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Sync Interval
|
||||
</label>
|
||||
<select
|
||||
x-model="settingsForm.sync_interval_minutes"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="15">Every 15 minutes</option>
|
||||
<option value="30">Every 30 minutes</option>
|
||||
<option value="60">Every hour</option>
|
||||
<option value="120">Every 2 hours</option>
|
||||
<option value="360">Every 6 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Last Sync Info -->
|
||||
<div x-show="credentials" class="mb-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Sync</h4>
|
||||
<div class="grid gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>
|
||||
<span class="font-medium">Status:</span>
|
||||
<span
|
||||
class="ml-2 px-2 py-0.5 text-xs rounded-full"
|
||||
:class="{
|
||||
'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300': credentials?.last_sync_status === 'success',
|
||||
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300': credentials?.last_sync_status === 'partial',
|
||||
'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300': credentials?.last_sync_status === 'failed',
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-600 dark:text-gray-300': !credentials?.last_sync_status
|
||||
}"
|
||||
x-text="credentials?.last_sync_status || 'Never'"
|
||||
></span>
|
||||
</p>
|
||||
<p x-show="credentials?.last_sync_at">
|
||||
<span class="font-medium">Time:</span>
|
||||
<span class="ml-2" x-text="formatDate(credentials?.last_sync_at)"></span>
|
||||
</p>
|
||||
<p x-show="credentials?.last_sync_error" class="text-red-600 dark:text-red-400">
|
||||
<span class="font-medium">Error:</span>
|
||||
<span class="ml-2" x-text="credentials?.last_sync_error"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="savingCredentials"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!savingCredentials" x-html="$icon('save', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="savingCredentials" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="savingCredentials ? 'Saving...' : 'Save Credentials'"></span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="testConnection()"
|
||||
:disabled="testingConnection || !letzshopStatus.is_configured"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!testingConnection" x-html="$icon('lightning-bolt', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="testingConnection" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="testingConnection ? 'Testing...' : 'Test Connection'"></span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
x-show="credentials"
|
||||
@click="deleteCredentials()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-red-600 transition-colors duration-150 bg-white dark:bg-gray-800 border border-red-300 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('trash', 'w-4 h-4 mr-2')"></span>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSV URLs Card -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Letzshop CSV URLs
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure the CSV feed URLs for product imports. These URLs are used for quick-fill in the Products tab.
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="saveCsvUrls()">
|
||||
<!-- French CSV URL -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
<span class="fi fi-fr mr-2"></span>
|
||||
French CSV URL
|
||||
</label>
|
||||
<input
|
||||
x-model="settingsForm.letzshop_csv_url_fr"
|
||||
type="url"
|
||||
placeholder="https://letzshop.lu/feeds/products_fr.csv"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- English CSV URL -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
<span class="fi fi-gb mr-2"></span>
|
||||
English CSV URL
|
||||
</label>
|
||||
<input
|
||||
x-model="settingsForm.letzshop_csv_url_en"
|
||||
type="url"
|
||||
placeholder="https://letzshop.lu/feeds/products_en.csv"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- German CSV URL -->
|
||||
<div class="mb-6">
|
||||
<label class="flex items-center text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
<span class="fi fi-de mr-2"></span>
|
||||
German CSV URL
|
||||
</label>
|
||||
<input
|
||||
x-model="settingsForm.letzshop_csv_url_de"
|
||||
type="url"
|
||||
placeholder="https://letzshop.lu/feeds/products_de.csv"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="savingCsvUrls"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!savingCsvUrls" x-html="$icon('save', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="savingCsvUrls" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="savingCsvUrls ? 'Saving...' : 'Save CSV URLs'"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div class="flex">
|
||||
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-500 mr-2 flex-shrink-0')"></span>
|
||||
<div class="text-sm text-blue-700 dark:text-blue-300">
|
||||
<p class="font-medium">About CSV URLs</p>
|
||||
<p class="mt-1">These URLs should point to the vendor's product feed on Letzshop. The feed is typically provided by Letzshop as part of the merchant integration.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import/Export Settings Card -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Import / Export Settings
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure settings for product import and export operations.
|
||||
</p>
|
||||
|
||||
<!-- Import Settings -->
|
||||
<div class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||
<span x-html="$icon('cloud-download', 'w-4 h-4 mr-2 text-purple-500')"></span>
|
||||
Import Settings
|
||||
</h4>
|
||||
<div class="pl-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Batch Size
|
||||
</label>
|
||||
{{ number_stepper(model='importForm.batch_size', min=100, max=5000, step=100, label='Batch Size') }}
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Products processed per batch (100-5000). Higher = faster but more memory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Settings -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-600 pt-6">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||
<span x-html="$icon('upload', 'w-4 h-4 mr-2 text-green-500')"></span>
|
||||
Export Settings
|
||||
</h4>
|
||||
<div class="pl-6">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="exportIncludeInactive"
|
||||
class="form-checkbox h-5 w-5 text-purple-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:ring-purple-500"
|
||||
/>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-400">Include inactive products</span>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8">
|
||||
Export products that are currently marked as inactive
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Info Box -->
|
||||
<div class="mt-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Export Behavior</h4>
|
||||
<ul class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
|
||||
Exports all languages (FR, DE, EN) automatically
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
|
||||
CSV files are placed in a folder for Letzshop pickup
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span x-html="$icon('check', 'w-3 h-3 mr-2 text-green-500')"></span>
|
||||
Letzshop scheduler fetches files periodically
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Carrier Settings Card -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 lg:col-span-2">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Carrier Settings
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure default carrier and label URL prefixes for shipping labels.
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="saveCarrierSettings()">
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<!-- Default Carrier -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Default Carrier
|
||||
</label>
|
||||
<select
|
||||
x-model="settingsForm.default_carrier"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">-- Select carrier --</option>
|
||||
<option value="greco">Greco</option>
|
||||
<option value="colissimo">Colissimo</option>
|
||||
<option value="xpresslogistics">XpressLogistics</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Letzshop automatically assigns carriers based on shipment data
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder for alignment -->
|
||||
<div></div>
|
||||
|
||||
<!-- Greco Label URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
<span class="inline-flex items-center">
|
||||
<span class="w-3 h-3 rounded-full bg-blue-500 mr-2"></span>
|
||||
Greco Label URL Prefix
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
x-model="settingsForm.carrier_greco_label_url"
|
||||
type="url"
|
||||
placeholder="https://dispatchweb.fr/Tracky/Home/"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Label URL = Prefix + Shipment Number (e.g., H74683403433)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Colissimo Label URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
<span class="inline-flex items-center">
|
||||
<span class="w-3 h-3 rounded-full bg-yellow-500 mr-2"></span>
|
||||
Colissimo Label URL Prefix
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
x-model="settingsForm.carrier_colissimo_label_url"
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- XpressLogistics Label URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
<span class="inline-flex items-center">
|
||||
<span class="w-3 h-3 rounded-full bg-green-500 mr-2"></span>
|
||||
XpressLogistics Label URL Prefix
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
x-model="settingsForm.carrier_xpresslogistics_label_url"
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="savingCarrierSettings"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!savingCarrierSettings" x-html="$icon('save', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="savingCarrierSettings" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="savingCarrierSettings ? 'Saving...' : 'Save Carrier Settings'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user