diff --git a/app/api/v1/admin/vendor_products.py b/app/api/v1/admin/vendor_products.py
index 2a2bb4c1..0318bcaa 100644
--- a/app/api/v1/admin/vendor_products.py
+++ b/app/api/v1/admin/vendor_products.py
@@ -25,10 +25,13 @@ from models.schema.vendor_product import (
CatalogVendor,
CatalogVendorsResponse,
RemoveProductResponse,
+ VendorProductCreate,
+ VendorProductCreateResponse,
VendorProductDetail,
VendorProductListItem,
VendorProductListResponse,
VendorProductStats,
+ VendorProductUpdate,
)
router = APIRouter(prefix="/vendor-products")
@@ -109,6 +112,37 @@ def get_vendor_product_detail(
return VendorProductDetail(**product)
+@router.post("", response_model=VendorProductCreateResponse)
+def create_vendor_product(
+ data: VendorProductCreate,
+ db: Session = Depends(get_db),
+ current_admin: User = Depends(get_current_admin_api),
+):
+ """Create a new vendor product."""
+ product = vendor_product_service.create_product(db, data.model_dump())
+ db.commit() # ✅ ARCH: Commit at API level for transaction control
+ return VendorProductCreateResponse(
+ id=product.id, message="Product created successfully"
+ )
+
+
+@router.patch("/{product_id}", response_model=VendorProductDetail)
+def update_vendor_product(
+ product_id: int,
+ data: VendorProductUpdate,
+ db: Session = Depends(get_db),
+ current_admin: User = Depends(get_current_admin_api),
+):
+ """Update a vendor product."""
+ # Only include fields that were explicitly set
+ update_data = data.model_dump(exclude_unset=True)
+ vendor_product_service.update_product(db, product_id, update_data)
+ db.commit() # ✅ ARCH: Commit at API level for transaction control
+ # Return the updated product detail
+ product = vendor_product_service.get_product_detail(db, product_id)
+ return VendorProductDetail(**product)
+
+
@router.delete("/{product_id}", response_model=RemoveProductResponse)
def remove_vendor_product(
product_id: int,
diff --git a/app/routes/admin_pages.py b/app/routes/admin_pages.py
index 57434514..4343a660 100644
--- a/app/routes/admin_pages.py
+++ b/app/routes/admin_pages.py
@@ -807,6 +807,25 @@ async def admin_vendor_products_page(
)
+@router.get("/vendor-products/create", response_class=HTMLResponse, include_in_schema=False)
+async def admin_vendor_product_create_page(
+ request: Request,
+ current_user: User = Depends(get_current_admin_from_cookie_or_header),
+ db: Session = Depends(get_db),
+):
+ """
+ Render vendor product create page.
+ Create a new vendor product entry.
+ """
+ return templates.TemplateResponse(
+ "admin/vendor-product-create.html",
+ {
+ "request": request,
+ "user": current_user,
+ },
+ )
+
+
@router.get(
"/vendor-products/{product_id}",
response_class=HTMLResponse,
@@ -832,6 +851,31 @@ async def admin_vendor_product_detail_page(
)
+@router.get(
+ "/vendor-products/{product_id}/edit",
+ response_class=HTMLResponse,
+ include_in_schema=False,
+)
+async def admin_vendor_product_edit_page(
+ request: Request,
+ product_id: int = Path(..., description="Vendor Product ID"),
+ current_user: User = Depends(get_current_admin_from_cookie_or_header),
+ db: Session = Depends(get_db),
+):
+ """
+ Render vendor product edit page.
+ Edit vendor product information and overrides.
+ """
+ return templates.TemplateResponse(
+ "admin/vendor-product-edit.html",
+ {
+ "request": request,
+ "user": current_user,
+ "product_id": product_id,
+ },
+ )
+
+
# ============================================================================
# SETTINGS ROUTES
# ============================================================================
diff --git a/app/services/vendor_product_service.py b/app/services/vendor_product_service.py
index e7d10370..772db8b6 100644
--- a/app/services/vendor_product_service.py
+++ b/app/services/vendor_product_service.py
@@ -238,6 +238,78 @@ class VendorProductService:
else None,
}
+ def create_product(self, db: Session, data: dict) -> Product:
+ """Create a new vendor product.
+
+ Args:
+ db: Database session
+ data: Product data dict
+
+ Returns:
+ Created Product instance
+ """
+ product = Product(
+ vendor_id=data["vendor_id"],
+ vendor_sku=data.get("vendor_sku"),
+ brand=data.get("brand"),
+ gtin=data.get("gtin"),
+ price=data.get("price"),
+ currency=data.get("currency", "EUR"),
+ availability=data.get("availability"),
+ is_active=data.get("is_active", True),
+ is_featured=data.get("is_featured", False),
+ is_digital=data.get("is_digital", False),
+ description=data.get("description"),
+ )
+
+ db.add(product)
+ db.flush()
+
+ logger.info(f"Created vendor product {product.id} for vendor {data['vendor_id']}")
+
+ return product
+
+ def update_product(self, db: Session, product_id: int, data: dict) -> Product:
+ """Update a vendor product.
+
+ Args:
+ db: Database session
+ product_id: Product ID to update
+ data: Fields to update
+
+ Returns:
+ Updated Product instance
+ """
+ product = db.query(Product).filter(Product.id == product_id).first()
+
+ if not product:
+ raise ProductNotFoundException(product_id)
+
+ # Update allowed fields
+ updatable_fields = [
+ "vendor_sku",
+ "brand",
+ "gtin",
+ "price_override",
+ "currency_override",
+ "availability",
+ "is_active",
+ "is_featured",
+ "is_digital",
+ "description",
+ "title",
+ ]
+
+ for field in updatable_fields:
+ if field in data:
+ setattr(product, field, data[field])
+
+ db.flush()
+
+ logger.info(f"Updated vendor product {product_id}")
+
+ return product
+
def remove_product(self, db: Session, product_id: int) -> dict:
"""Remove a product from vendor catalog."""
product = db.query(Product).filter(Product.id == product_id).first()
@@ -273,6 +345,9 @@ class VendorProductService:
"brand": product.brand,
"price": product.price,
"currency": product.currency,
+ # Effective price/currency for UI (same as price/currency for now)
+ "effective_price": product.price,
+ "effective_currency": product.currency,
"is_active": product.is_active,
"is_featured": product.is_featured,
"is_digital": product.is_digital,
diff --git a/app/templates/admin/vendor-product-create.html b/app/templates/admin/vendor-product-create.html
new file mode 100644
index 00000000..df5a85fe
--- /dev/null
+++ b/app/templates/admin/vendor-product-create.html
@@ -0,0 +1,242 @@
+{# app/templates/admin/vendor-product-create.html #}
+{% extends "admin/base.html" %}
+{% from 'shared/macros/headers.html' import detail_page_header %}
+
+{% block title %}Create Vendor Product{% endblock %}
+
+{% block alpine_data %}adminVendorProductCreate(){% endblock %}
+
+{% block extra_head %}
+
+
+
+{% endblock %}
+
+{% block content %}
+{% call detail_page_header("'Create Vendor Product'", '/admin/vendor-products') %}
+ Add a new product to a vendor's catalog
+{% endcall %}
+
+
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+
+
+{% endblock %}
diff --git a/app/templates/admin/vendor-product-edit.html b/app/templates/admin/vendor-product-edit.html
new file mode 100644
index 00000000..213eee1a
--- /dev/null
+++ b/app/templates/admin/vendor-product-edit.html
@@ -0,0 +1,180 @@
+{# app/templates/admin/vendor-product-edit.html #}
+{% extends "admin/base.html" %}
+{% from 'shared/macros/alerts.html' import loading_state, error_state %}
+{% from 'shared/macros/headers.html' import detail_page_header %}
+
+{% block title %}Edit Vendor Product{% endblock %}
+
+{% block alpine_data %}adminVendorProductEdit(){% endblock %}
+
+{% block content %}
+{% call detail_page_header("'Edit: ' + (product?.title || 'Product')", '/admin/vendor-products', subtitle_show='product') %}
+
+{% endcall %}
+
+{{ loading_state('Loading product...') }}
+
+{{ error_state('Error loading product') }}
+
+
+
+
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/app/templates/admin/vendor-products.html b/app/templates/admin/vendor-products.html
index 1a7522cb..c867adc2 100644
--- a/app/templates/admin/vendor-products.html
+++ b/app/templates/admin/vendor-products.html
@@ -62,6 +62,13 @@
{{ refresh_button(loading_var='loading', onclick='refresh()', variant='secondary') }}
+
+
+ Create Product
+
{% endcall %}
@@ -316,21 +323,21 @@
-
+
diff --git a/models/schema/vendor_product.py b/models/schema/vendor_product.py
index 5d21533f..0a312a46 100644
--- a/models/schema/vendor_product.py
+++ b/models/schema/vendor_product.py
@@ -21,12 +21,14 @@ class VendorProductListItem(BaseModel):
vendor_id: int
vendor_name: str | None = None
vendor_code: str | None = None
- marketplace_product_id: int
+ marketplace_product_id: int | None = None
vendor_sku: str | None = None
title: str | None = None
brand: str | None = None
price: float | None = None
currency: str | None = None
+ effective_price: float | None = None
+ effective_currency: str | None = None
is_active: bool | None = None
is_featured: bool | None = None
is_digital: bool | None = None
@@ -142,3 +144,43 @@ class RemoveProductResponse(BaseModel):
"""Response from product removal."""
message: str
+
+
+class VendorProductCreate(BaseModel):
+ """Schema for creating a vendor product."""
+
+ vendor_id: int
+ title: str
+ brand: str | None = None
+ vendor_sku: str | None = None
+ gtin: str | None = None
+ price: float | None = None
+ currency: str = "EUR"
+ availability: str | None = None
+ is_active: bool = True
+ is_featured: bool = False
+ is_digital: bool = False
+ description: str | None = None
+
+
+class VendorProductUpdate(BaseModel):
+ """Schema for updating a vendor product."""
+
+ title: str | None = None
+ brand: str | None = None
+ vendor_sku: str | None = None
+ gtin: str | None = None
+ price_override: float | None = None
+ currency_override: str | None = None
+ availability: str | None = None
+ is_active: bool | None = None
+ is_featured: bool | None = None
+ is_digital: bool | None = None
+ description: str | None = None
+
+
+class VendorProductCreateResponse(BaseModel):
+ """Response from product creation."""
+
+ id: int
+ message: str
diff --git a/static/admin/js/vendor-product-create.js b/static/admin/js/vendor-product-create.js
new file mode 100644
index 00000000..2f869496
--- /dev/null
+++ b/static/admin/js/vendor-product-create.js
@@ -0,0 +1,164 @@
+// static/admin/js/vendor-product-create.js
+/**
+ * Admin vendor product create page logic
+ * Create new vendor product entries
+ */
+
+const adminVendorProductCreateLog = window.LogConfig.loggers.adminVendorProductCreate ||
+ window.LogConfig.createLogger('adminVendorProductCreate', false);
+
+adminVendorProductCreateLog.info('Loading...');
+
+function adminVendorProductCreate() {
+ adminVendorProductCreateLog.info('adminVendorProductCreate() called');
+
+ return {
+ // Inherit base layout state
+ ...data(),
+
+ // Set page identifier
+ currentPage: 'vendor-products',
+
+ // Loading states
+ saving: false,
+
+ // Tom Select instance
+ vendorSelectInstance: null,
+
+ // Form data
+ form: {
+ vendor_id: null,
+ title: '',
+ brand: '',
+ vendor_sku: '',
+ gtin: '',
+ price: null,
+ currency: 'EUR',
+ availability: '',
+ is_active: true,
+ is_featured: false,
+ is_digital: false,
+ description: ''
+ },
+
+ async init() {
+ adminVendorProductCreateLog.info('Vendor Product Create init() called');
+
+ // Guard against multiple initialization
+ if (window._adminVendorProductCreateInitialized) {
+ adminVendorProductCreateLog.warn('Already initialized, skipping');
+ return;
+ }
+ window._adminVendorProductCreateInitialized = true;
+
+ // Initialize Tom Select
+ this.initVendorSelect();
+
+ adminVendorProductCreateLog.info('Vendor Product Create initialization complete');
+ },
+
+ /**
+ * Initialize Tom Select for vendor autocomplete
+ */
+ initVendorSelect() {
+ const selectEl = this.$refs.vendorSelect;
+ if (!selectEl) {
+ adminVendorProductCreateLog.warn('Vendor select element not found');
+ return;
+ }
+
+ // Wait for Tom Select to be available
+ if (typeof TomSelect === 'undefined') {
+ adminVendorProductCreateLog.warn('TomSelect not loaded, retrying in 100ms');
+ setTimeout(() => this.initVendorSelect(), 100);
+ return;
+ }
+
+ this.vendorSelectInstance = new TomSelect(selectEl, {
+ valueField: 'id',
+ labelField: 'name',
+ searchField: ['name', 'vendor_code'],
+ placeholder: 'Search vendor...',
+ load: async (query, callback) => {
+ try {
+ const response = await apiClient.get('/admin/vendors', {
+ search: query,
+ limit: 50
+ });
+ callback(response.vendors || []);
+ } catch (error) {
+ adminVendorProductCreateLog.error('Failed to search vendors:', error);
+ callback([]);
+ }
+ },
+ render: {
+ option: (data, escape) => {
+ return `
+ ${escape(data.name)}
+ ${escape(data.vendor_code || '')}
+
`;
+ },
+ item: (data, escape) => {
+ return `${escape(data.name)}
`;
+ }
+ },
+ onChange: (value) => {
+ this.form.vendor_id = value ? parseInt(value) : null;
+ }
+ });
+
+ adminVendorProductCreateLog.info('Vendor select initialized');
+ },
+
+ /**
+ * Create the product
+ */
+ async createProduct() {
+ if (!this.form.vendor_id) {
+ Utils.showToast('Please select a vendor', 'error');
+ return;
+ }
+
+ if (!this.form.title) {
+ Utils.showToast('Please enter a product title', 'error');
+ return;
+ }
+
+ this.saving = true;
+
+ try {
+ // Build create payload
+ const payload = {
+ vendor_id: this.form.vendor_id,
+ title: this.form.title,
+ brand: this.form.brand || null,
+ vendor_sku: this.form.vendor_sku || null,
+ gtin: this.form.gtin || null,
+ price: this.form.price ? parseFloat(this.form.price) : null,
+ currency: this.form.currency || 'EUR',
+ availability: this.form.availability || null,
+ is_active: this.form.is_active,
+ is_featured: this.form.is_featured,
+ is_digital: this.form.is_digital,
+ description: this.form.description || null
+ };
+
+ const response = await apiClient.post('/admin/vendor-products', payload);
+
+ adminVendorProductCreateLog.info('Product created:', response.id);
+
+ Utils.showToast('Product created successfully', 'success');
+
+ // Redirect to the new product's detail page
+ setTimeout(() => {
+ window.location.href = `/admin/vendor-products/${response.id}`;
+ }, 1000);
+ } catch (error) {
+ adminVendorProductCreateLog.error('Failed to create product:', error);
+ Utils.showToast(error.message || 'Failed to create product', 'error');
+ } finally {
+ this.saving = false;
+ }
+ }
+ };
+}
diff --git a/static/admin/js/vendor-product-edit.js b/static/admin/js/vendor-product-edit.js
new file mode 100644
index 00000000..65a2e703
--- /dev/null
+++ b/static/admin/js/vendor-product-edit.js
@@ -0,0 +1,143 @@
+// static/admin/js/vendor-product-edit.js
+/**
+ * Admin vendor product edit page logic
+ * Edit vendor product information and overrides
+ */
+
+const adminVendorProductEditLog = window.LogConfig.loggers.adminVendorProductEdit ||
+ window.LogConfig.createLogger('adminVendorProductEdit', false);
+
+adminVendorProductEditLog.info('Loading...');
+
+function adminVendorProductEdit() {
+ adminVendorProductEditLog.info('adminVendorProductEdit() called');
+
+ // Extract product ID from URL
+ const pathParts = window.location.pathname.split('/');
+ const productId = parseInt(pathParts[pathParts.length - 2]); // /vendor-products/{id}/edit
+
+ return {
+ // Inherit base layout state
+ ...data(),
+
+ // Set page identifier
+ currentPage: 'vendor-products',
+
+ // Product ID from URL
+ productId: productId,
+
+ // Loading states
+ loading: true,
+ saving: false,
+ error: '',
+
+ // Product data
+ product: null,
+
+ // Form data
+ form: {
+ title: '',
+ brand: '',
+ vendor_sku: '',
+ gtin: '',
+ price_override: null,
+ currency_override: '',
+ availability: '',
+ is_active: true,
+ is_featured: false,
+ is_digital: false,
+ description: ''
+ },
+
+ async init() {
+ adminVendorProductEditLog.info('Vendor Product Edit init() called, ID:', this.productId);
+
+ // Guard against multiple initialization
+ if (window._adminVendorProductEditInitialized) {
+ adminVendorProductEditLog.warn('Already initialized, skipping');
+ return;
+ }
+ window._adminVendorProductEditInitialized = true;
+
+ // Load product data
+ await this.loadProduct();
+
+ adminVendorProductEditLog.info('Vendor Product Edit initialization complete');
+ },
+
+ /**
+ * Load product details and populate form
+ */
+ async loadProduct() {
+ this.loading = true;
+ this.error = '';
+
+ try {
+ const response = await apiClient.get(`/admin/vendor-products/${this.productId}`);
+ this.product = response;
+
+ // Populate form with current values
+ this.form = {
+ title: response.title || '',
+ brand: response.brand || '',
+ vendor_sku: response.vendor_sku || '',
+ gtin: response.gtin || '',
+ price_override: response.price_override || null,
+ currency_override: response.currency_override || '',
+ availability: response.availability || '',
+ is_active: response.is_active ?? true,
+ is_featured: response.is_featured ?? false,
+ is_digital: response.is_digital ?? false,
+ description: response.description || ''
+ };
+
+ adminVendorProductEditLog.info('Loaded product:', this.product.id);
+ } catch (error) {
+ adminVendorProductEditLog.error('Failed to load product:', error);
+ this.error = error.message || 'Failed to load product details';
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ /**
+ * Save product changes
+ */
+ async saveProduct() {
+ this.saving = true;
+
+ try {
+ // Build update payload
+ const payload = {
+ title: this.form.title || null,
+ brand: this.form.brand || null,
+ vendor_sku: this.form.vendor_sku || null,
+ gtin: this.form.gtin || null,
+ price_override: this.form.price_override ? parseFloat(this.form.price_override) : null,
+ currency_override: this.form.currency_override || null,
+ availability: this.form.availability || null,
+ is_active: this.form.is_active,
+ is_featured: this.form.is_featured,
+ is_digital: this.form.is_digital,
+ description: this.form.description || null
+ };
+
+ await apiClient.patch(`/admin/vendor-products/${this.productId}`, payload);
+
+ adminVendorProductEditLog.info('Product saved:', this.productId);
+
+ Utils.showToast('Product updated successfully', 'success');
+
+ // Redirect to detail page
+ setTimeout(() => {
+ window.location.href = `/admin/vendor-products/${this.productId}`;
+ }, 1000);
+ } catch (error) {
+ adminVendorProductEditLog.error('Failed to save product:', error);
+ Utils.showToast(error.message || 'Failed to save product', 'error');
+ } finally {
+ this.saving = false;
+ }
+ }
+ };
+}