Refactor item management: Update schemas, models, and API routes
- Renamed customs-related schemas in line_items to LineCustomCreate, LineCustomUpdate, and LineCustomResponse. - Adjusted models to streamline invoice_id mapping in Item model. - Enhanced item routes to include company_id in summary statistics endpoint. - Refactored ItemService to improve item creation and update logic, removing redundant methods. - Updated frontend components for item management, including new item creation and editing functionalities. - Added API client for items with CRUD operations and improved error handling.
This commit is contained in:
@@ -4,9 +4,9 @@ from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
# Import nested schemas
|
||||
from ..line_customs.schemas import (
|
||||
LineCustomsCreate,
|
||||
LineCustomsUpdate,
|
||||
LineCustomsResponse
|
||||
LineCustomCreate,
|
||||
LineCustomUpdate,
|
||||
LineCustomResponse
|
||||
)
|
||||
from ..line_descriptions.schemas import (
|
||||
LineDescriptionCreate,
|
||||
@@ -137,7 +137,7 @@ class LineItemCreate(LineItemBase):
|
||||
"""Schema for creating line item with all nested data"""
|
||||
financial: Optional[LineFinancialCreate] = Field(None, description="Financial data for this line")
|
||||
quantity: Optional[LineQuantityCreate] = Field(None, description="Quantity data for this line")
|
||||
customs: Optional[LineCustomsCreate] = Field(None, description="Customs data for this line")
|
||||
customs: Optional[LineCustomCreate] = Field(None, description="Customs data for this line")
|
||||
description: Optional[LineDescriptionCreate] = Field(None, description="Description data for this line")
|
||||
reference: Optional[LineReferenceCreate] = Field(None, description="Reference data for this line")
|
||||
|
||||
@@ -147,7 +147,7 @@ class LineItemUpdate(LineItemBase):
|
||||
line_number: Optional[int] = Field(None, description="Line number")
|
||||
financial: Optional[LineFinancialUpdate] = Field(None, description="Financial data for this line")
|
||||
quantity: Optional[LineQuantityUpdate] = Field(None, description="Quantity data for this line")
|
||||
customs: Optional[LineCustomsUpdate] = Field(None, description="Customs data for this line")
|
||||
customs: Optional[LineCustomUpdate] = Field(None, description="Customs data for this line")
|
||||
description: Optional[LineDescriptionUpdate] = Field(None, description="Description data for this line")
|
||||
reference: Optional[LineReferenceUpdate] = Field(None, description="Reference data for this line")
|
||||
|
||||
@@ -158,7 +158,7 @@ class LineItemResponse(LineItemBase):
|
||||
item_id: int
|
||||
financial: Optional[LineFinancialResponse] = None
|
||||
quantity: Optional[LineQuantityResponse] = None
|
||||
customs: Optional[LineCustomsResponse] = None
|
||||
customs: Optional[LineCustomResponse] = None
|
||||
description: Optional[LineDescriptionResponse] = None
|
||||
reference: Optional[LineReferenceResponse] = None
|
||||
|
||||
|
||||
@@ -28,8 +28,7 @@ class Item(Base, TenantScopedMixin, TimestampMixin):
|
||||
}
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
invoice_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("a76.invoice_header.id")) # CONSECUTIVO
|
||||
invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id")) # CONSECUTIVO
|
||||
# Type: IMPORT_TEMP, IMPORT_DEF, EXPORT, REPAIR, etc.
|
||||
item_type: Mapped[str] = mapped_column(String(20))
|
||||
system_origin: Mapped[str] = mapped_column(String(10)) # SCAF or SCAII
|
||||
|
||||
@@ -198,6 +198,7 @@ async def get_items_by_invoice(
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def get_items_summary(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
invoice_id: Optional[int] = Query(
|
||||
None, description="Filter by invoice ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
@@ -206,11 +207,16 @@ async def get_items_summary(
|
||||
"""
|
||||
Get summary statistics for items
|
||||
"""
|
||||
service = ItemService(db)
|
||||
items, total = service.list_items(
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
filters = {"invoice_id": invoice_id} if invoice_id else None
|
||||
items, total = ItemService.get_all(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
skip=0,
|
||||
limit=10000, # Get all for stats
|
||||
invoice_id=invoice_id
|
||||
filters=filters
|
||||
)
|
||||
|
||||
# Calculate stats
|
||||
|
||||
@@ -344,457 +344,3 @@ class ItemService:
|
||||
db.rollback()
|
||||
logger.error(f"Error deleting item: {e}")
|
||||
raise HTTPException(status_code=500, detail="Error deleting item")
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_item(self, item_data: ItemCreate) -> Item:
|
||||
"""
|
||||
Create a new item with its nested line item (one-to-one)
|
||||
Also creates the line's financial and quantity data
|
||||
"""
|
||||
try:
|
||||
# Extract line data before creating the item
|
||||
line_data = item_data.line
|
||||
item_dict = item_data.model_dump(exclude={'line'})
|
||||
|
||||
# Create the item
|
||||
db_item = Item(**item_dict)
|
||||
self.db.add(db_item)
|
||||
self.db.flush() # Get the item ID without committing
|
||||
|
||||
# Create line item with nested data if provided
|
||||
if line_data:
|
||||
self._create_line_item(db_item.id, line_data)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(db_item)
|
||||
return db_item
|
||||
|
||||
except IntegrityError as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Integrity error creating item: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Item creation failed due to data integrity constraint"
|
||||
)
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Unexpected error creating item: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error creating item: {str(e)}"
|
||||
)
|
||||
|
||||
def _create_line_item(self, item_id: int, line_data: LineItemCreate) -> LineItem:
|
||||
"""
|
||||
Create a line item with all its nested data (financial, quantity, customs, description, reference)
|
||||
"""
|
||||
# Extract nested data
|
||||
financial_data = line_data.financial
|
||||
quantity_data = line_data.quantity
|
||||
customs_data = line_data.customs
|
||||
description_data = line_data.description
|
||||
reference_data = line_data.reference
|
||||
|
||||
line_dict = line_data.model_dump(exclude={
|
||||
'financial', 'quantity', 'customs', 'description', 'reference'
|
||||
})
|
||||
|
||||
# Create line item
|
||||
db_line = LineItem(item_id=item_id, **line_dict)
|
||||
self.db.add(db_line)
|
||||
self.db.flush() # Get the line ID
|
||||
|
||||
# Create financial data if provided
|
||||
if financial_data:
|
||||
db_financial = LineFinancial(
|
||||
item_line_id=db_line.id,
|
||||
**financial_data.model_dump()
|
||||
)
|
||||
self.db.add(db_financial)
|
||||
|
||||
# Create quantity data if provided
|
||||
if quantity_data:
|
||||
db_quantity = LineQuantity(
|
||||
item_line_id=db_line.id,
|
||||
**quantity_data.model_dump()
|
||||
)
|
||||
self.db.add(db_quantity)
|
||||
|
||||
# Create customs data if provided
|
||||
if customs_data:
|
||||
db_customs = LineCustom(
|
||||
item_line_id=db_line.id,
|
||||
**customs_data.model_dump()
|
||||
)
|
||||
self.db.add(db_customs)
|
||||
|
||||
# Create description data if provided
|
||||
if description_data:
|
||||
db_description = LineDescription(
|
||||
item_line_id=db_line.id,
|
||||
**description_data.model_dump()
|
||||
)
|
||||
self.db.add(db_description)
|
||||
|
||||
# Create reference data if provided
|
||||
if reference_data:
|
||||
db_reference = LineReference(
|
||||
item_line_id=db_line.id,
|
||||
**reference_data.model_dump()
|
||||
)
|
||||
self.db.add(db_reference)
|
||||
|
||||
return db_line
|
||||
|
||||
def get_item(self, item_id: int) -> Optional[Item]:
|
||||
"""
|
||||
Get an item by ID with all nested data loaded
|
||||
"""
|
||||
item = (
|
||||
self.db.query(Item)
|
||||
.options(joinedload(Item.lines))
|
||||
.filter(Item.id == item_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not item:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Item with id {item_id} not found"
|
||||
)
|
||||
|
||||
return item
|
||||
|
||||
def list_items(
|
||||
self,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
invoice_id: Optional[int] = None,
|
||||
item_type: Optional[str] = None,
|
||||
system_origin: Optional[str] = None,
|
||||
) -> tuple[list[Item], int]:
|
||||
"""
|
||||
List items with optional filters and pagination
|
||||
Returns tuple of (items, total_count)
|
||||
"""
|
||||
query = self.db.query(Item).options(joinedload(Item.lines))
|
||||
|
||||
# Apply filters
|
||||
if invoice_id:
|
||||
query = query.filter(Item.invoice_id == invoice_id)
|
||||
if item_type:
|
||||
query = query.filter(Item.item_type == item_type)
|
||||
if system_origin:
|
||||
query = query.filter(Item.system_origin == system_origin)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination
|
||||
items = query.offset(skip).limit(limit).all()
|
||||
|
||||
return items, total
|
||||
|
||||
def update_item(self, item_id: int, item_data: ItemUpdate) -> Item:
|
||||
"""
|
||||
Update an item and optionally its line item (one-to-one)
|
||||
"""
|
||||
try:
|
||||
db_item = self.get_item(item_id)
|
||||
|
||||
# Extract line data
|
||||
line_data = item_data.line
|
||||
update_dict = item_data.model_dump(
|
||||
exclude={'line'}, exclude_unset=True)
|
||||
|
||||
# Update item fields
|
||||
for field, value in update_dict.items():
|
||||
setattr(db_item, field, value)
|
||||
|
||||
# Update line if provided
|
||||
if line_data is not None:
|
||||
# Get the existing line or create new one
|
||||
db_line = (
|
||||
self.db.query(LineItem)
|
||||
.filter(LineItem.item_id == item_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if db_line:
|
||||
self._update_line_item(db_line, line_data)
|
||||
else:
|
||||
# Create new line if it doesn't exist
|
||||
self._create_line_item(item_id, line_data)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(db_item)
|
||||
return db_item
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except IntegrityError as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Integrity error updating item: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Item update failed due to data integrity constraint"
|
||||
)
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Unexpected error updating item: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error updating item: {str(e)}"
|
||||
)
|
||||
|
||||
def _update_line_item(self, db_line: LineItem, line_data: LineItemUpdate):
|
||||
"""
|
||||
Update a line item and all its nested data
|
||||
"""
|
||||
# Extract nested data
|
||||
financial_data = line_data.financial
|
||||
quantity_data = line_data.quantity
|
||||
customs_data = line_data.customs
|
||||
description_data = line_data.description
|
||||
reference_data = line_data.reference
|
||||
|
||||
line_dict = line_data.model_dump(
|
||||
exclude={'financial', 'quantity',
|
||||
'customs', 'description', 'reference'},
|
||||
exclude_unset=True
|
||||
)
|
||||
|
||||
# Update line fields
|
||||
for field, value in line_dict.items():
|
||||
setattr(db_line, field, value)
|
||||
|
||||
# Update financial data
|
||||
if financial_data:
|
||||
db_financial = (
|
||||
self.db.query(LineFinancial)
|
||||
.filter(LineFinancial.item_line_id == db_line.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if db_financial:
|
||||
# Update existing
|
||||
for field, value in financial_data.model_dump(exclude_unset=True).items():
|
||||
setattr(db_financial, field, value)
|
||||
else:
|
||||
# Create new
|
||||
db_financial = LineFinancial(
|
||||
item_line_id=db_line.id,
|
||||
**financial_data.model_dump(exclude_unset=True)
|
||||
)
|
||||
self.db.add(db_financial)
|
||||
|
||||
# Update quantity data
|
||||
if quantity_data:
|
||||
db_quantity = (
|
||||
self.db.query(LineQuantity)
|
||||
.filter(LineQuantity.item_line_id == db_line.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if db_quantity:
|
||||
# Update existing
|
||||
for field, value in quantity_data.model_dump(exclude_unset=True).items():
|
||||
setattr(db_quantity, field, value)
|
||||
else:
|
||||
# Create new
|
||||
db_quantity = LineQuantity(
|
||||
item_line_id=db_line.id,
|
||||
**quantity_data.model_dump(exclude_unset=True)
|
||||
)
|
||||
self.db.add(db_quantity)
|
||||
|
||||
# Update customs data
|
||||
if customs_data:
|
||||
db_customs = (
|
||||
self.db.query(LineCustom)
|
||||
.filter(LineCustom.item_line_id == db_line.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if db_customs:
|
||||
# Update existing
|
||||
for field, value in customs_data.model_dump(exclude_unset=True).items():
|
||||
setattr(db_customs, field, value)
|
||||
else:
|
||||
# Create new
|
||||
db_customs = LineCustom(
|
||||
item_line_id=db_line.id,
|
||||
**customs_data.model_dump(exclude_unset=True)
|
||||
)
|
||||
self.db.add(db_customs)
|
||||
|
||||
# Update description data
|
||||
if description_data:
|
||||
db_description = (
|
||||
self.db.query(LineDescription)
|
||||
.filter(LineDescription.item_line_id == db_line.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if db_description:
|
||||
# Update existing
|
||||
for field, value in description_data.model_dump(exclude_unset=True).items():
|
||||
setattr(db_description, field, value)
|
||||
else:
|
||||
# Create new
|
||||
db_description = LineDescription(
|
||||
item_line_id=db_line.id,
|
||||
**description_data.model_dump(exclude_unset=True)
|
||||
)
|
||||
self.db.add(db_description)
|
||||
|
||||
# Update reference data
|
||||
if reference_data:
|
||||
db_reference = (
|
||||
self.db.query(LineReference)
|
||||
.filter(LineReference.item_line_id == db_line.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if db_reference:
|
||||
# Update existing
|
||||
for field, value in reference_data.model_dump(exclude_unset=True).items():
|
||||
setattr(db_reference, field, value)
|
||||
else:
|
||||
# Create new
|
||||
db_reference = LineReference(
|
||||
item_line_id=db_line.id,
|
||||
**reference_data.model_dump(exclude_unset=True)
|
||||
)
|
||||
self.db.add(db_reference)
|
||||
|
||||
def delete_item(self, item_id: int) -> bool:
|
||||
"""
|
||||
Delete an item and all its related data (cascade)
|
||||
"""
|
||||
try:
|
||||
db_item = self.get_item(item_id)
|
||||
self.db.delete(db_item)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error deleting item: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error deleting item: {str(e)}"
|
||||
)
|
||||
|
||||
def search_items(
|
||||
self,
|
||||
search_term: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100
|
||||
) -> tuple[list[Item], int]:
|
||||
"""
|
||||
Search items by various fields
|
||||
"""
|
||||
query = self.db.query(Item).options(joinedload(Item.lines))
|
||||
|
||||
if search_term:
|
||||
search_filter = or_(
|
||||
Item.invoice_number.ilike(f"%{search_term}%"),
|
||||
Item.reference_number.ilike(f"%{search_term}%"),
|
||||
Item.order.ilike(f"%{search_term}%"),
|
||||
Item.guide_number.ilike(f"%{search_term}%"),
|
||||
)
|
||||
query = query.filter(search_filter)
|
||||
|
||||
total = query.count()
|
||||
items = query.offset(skip).limit(limit).all()
|
||||
|
||||
return items, total
|
||||
|
||||
# ========================================================================
|
||||
# LINE ITEM SPECIFIC OPERATIONS (one-to-one)
|
||||
# ========================================================================
|
||||
|
||||
def get_line_for_item(self, item_id: int) -> Optional[LineItem]:
|
||||
"""
|
||||
Get the line item for a specific item
|
||||
"""
|
||||
db_line = (
|
||||
self.db.query(LineItem)
|
||||
.filter(LineItem.item_id == item_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
return db_line
|
||||
|
||||
def create_or_replace_line(self, item_id: int, line_data: LineItemCreate) -> LineItem:
|
||||
"""
|
||||
Create or replace the line for an item (one-to-one relationship)
|
||||
"""
|
||||
try:
|
||||
# Verify item exists
|
||||
db_item = self.get_item(item_id)
|
||||
|
||||
# Check if line already exists
|
||||
existing_line = (
|
||||
self.db.query(LineItem)
|
||||
.filter(LineItem.item_id == item_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing_line:
|
||||
# Delete existing line (cascade will delete financials and quantities)
|
||||
self.db.delete(existing_line)
|
||||
self.db.flush()
|
||||
|
||||
# Create new line
|
||||
db_line = self._create_line_item(item_id, line_data)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(db_line)
|
||||
return db_line
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error creating/replacing line for item: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error creating/replacing line for item: {str(e)}"
|
||||
)
|
||||
|
||||
def delete_line_from_item(self, item_id: int) -> bool:
|
||||
"""
|
||||
Delete the line from an item
|
||||
"""
|
||||
try:
|
||||
db_line = (
|
||||
self.db.query(LineItem)
|
||||
.filter(LineItem.item_id == item_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not db_line:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No line found for item {item_id}"
|
||||
)
|
||||
|
||||
self.db.delete(db_line)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error(f"Error deleting line from item: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error deleting line from item: {str(e)}"
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from .customs_brokers.routes import router as customs_broker_router
|
||||
|
||||
# Importar routers de módulos
|
||||
from .invoices.routes import router as invoices_router
|
||||
from .items.routes import router as items_router
|
||||
from .classes import router as classes_router
|
||||
from .clients_and_providers import router as client_and_provider_router
|
||||
from .general_catalogs.company import router as company_router
|
||||
@@ -46,6 +47,7 @@ router = APIRouter()
|
||||
|
||||
# Registrar módulos
|
||||
router.include_router(invoices_router, prefix="/a76", tags=["a76 / invoices"])
|
||||
router.include_router(items_router, prefix="/a76", tags=["a76 / items"])
|
||||
router.include_router(pedimentos_router, prefix="/a76")
|
||||
router.include_router(
|
||||
client_and_provider_router, prefix="/a76", tags=["a76 / clients_and_providers"]
|
||||
|
||||
133
frontend/src/lib/api/dashboard/a76/items.ts
Normal file
133
frontend/src/lib/api/dashboard/a76/items.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* API Client para Items
|
||||
* Gestiona las operaciones CRUD para items de facturas
|
||||
*/
|
||||
import { api } from '$lib/api';
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
export interface Item {
|
||||
id?: number;
|
||||
invoice_id: number;
|
||||
item_type: string;
|
||||
system_origin: string;
|
||||
invoice_number?: string;
|
||||
reference_number?: string;
|
||||
order?: string;
|
||||
guide_number?: string;
|
||||
invoice_date?: number;
|
||||
depreciation_date?: number;
|
||||
rectification?: number;
|
||||
warehouse?: string;
|
||||
location?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface ItemListResponse {
|
||||
items: Item[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface CreateItemData {
|
||||
invoice_id: number;
|
||||
item_type: string;
|
||||
system_origin: string;
|
||||
invoice_number?: string;
|
||||
reference_number?: string;
|
||||
order?: string;
|
||||
guide_number?: string;
|
||||
invoice_date?: number;
|
||||
depreciation_date?: number;
|
||||
rectification?: number;
|
||||
warehouse?: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export interface UpdateItemData {
|
||||
item_type?: string;
|
||||
system_origin?: string;
|
||||
invoice_number?: string;
|
||||
reference_number?: string;
|
||||
order?: string;
|
||||
guide_number?: string;
|
||||
invoice_date?: number;
|
||||
depreciation_date?: number;
|
||||
rectification?: boolean;
|
||||
warehouse?: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API para Items
|
||||
*/
|
||||
export const itemsApi = {
|
||||
/**
|
||||
* Lista todos los items con paginación
|
||||
*/
|
||||
list: (companyId: number, skip = 0, limit = 100, invoiceId?: number) => {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString(),
|
||||
skip: skip.toString(),
|
||||
limit: limit.toString()
|
||||
});
|
||||
|
||||
if (invoiceId) {
|
||||
params.append('invoice_id', invoiceId.toString());
|
||||
}
|
||||
|
||||
return api.get<ItemListResponse>(`/v1/a76/items/?${params.toString()}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Lista items por invoice ID
|
||||
*/
|
||||
listByInvoice: (invoiceId: number, companyId: number) => {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.get<ItemListResponse>(`/v1/a76/items/invoice/${invoiceId}/items?${params.toString()}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtiene un item por ID
|
||||
*/
|
||||
get: (itemId: number, companyId: number) => {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.get<Item>(`/v1/a76/items/${itemId}?${params.toString()}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Crea un nuevo item
|
||||
*/
|
||||
create: (companyId: number, data: CreateItemData) => {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.post<Item>(`/v1/a76/items/?${params.toString()}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Actualiza un item existente
|
||||
*/
|
||||
update: (itemId: number, companyId: number, data: UpdateItemData) => {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.put<Item>(`/v1/a76/items/${itemId}?${params.toString()}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Elimina un item
|
||||
*/
|
||||
delete: (itemId: number, companyId: number) => {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.delete(`/v1/a76/items/${itemId}?${params.toString()}`);
|
||||
}
|
||||
};
|
||||
@@ -1,14 +1,16 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { RadioGroup, RadioGroupItem } from '$lib/components/ui/radio-group';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { Plus, Upload } from 'lucide-svelte';
|
||||
import { Plus, Pencil, Trash2, X, Loader2 } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
|
||||
import { itemsApi, type Item } from '$lib/api/dashboard/a76/items';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
let {
|
||||
invoice,
|
||||
@@ -23,22 +25,303 @@
|
||||
let imported = 0;
|
||||
let net_weight = 0;
|
||||
let gross_weight = 0;
|
||||
|
||||
let items = $state<Item[]>([]);
|
||||
let displayedItems = $state<Item[]>([]);
|
||||
let itemsPerPage = 20;
|
||||
let currentPage = $state(1);
|
||||
let tableContainer: HTMLDivElement | undefined = $state();
|
||||
let isLoadingMore = $state(false);
|
||||
let isLoadingItems = $state(false);
|
||||
let isSaving = $state(false);
|
||||
|
||||
// Sheet states
|
||||
let showItemSheet = $state(false);
|
||||
let isEditMode = $state(false);
|
||||
let showDeleteDialog = $state(false);
|
||||
let selectedItem = $state<Item | null>(null);
|
||||
let editingItem = $state<Partial<Item>>({
|
||||
invoice_id: undefined,
|
||||
item_type: '',
|
||||
invoice_number: '',
|
||||
reference_number: '',
|
||||
order: '',
|
||||
warehouse: '',
|
||||
location: ''
|
||||
});
|
||||
|
||||
// Derived value para company ID
|
||||
const activeCompanyId = $derived(companyStore.activeCompany?.id);
|
||||
|
||||
// Cargar items cuando la factura tenga ID
|
||||
$effect(() => {
|
||||
if (invoice?.id && activeCompanyId) {
|
||||
loadItems();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadItems() {
|
||||
if (!invoice?.id || !activeCompanyId) return;
|
||||
|
||||
isLoadingItems = true;
|
||||
try {
|
||||
const response = await itemsApi.listByInvoice(invoice.id, activeCompanyId);
|
||||
if (response.data) {
|
||||
items = response.data.items || [];
|
||||
currentPage = 1;
|
||||
loadMoreItems();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error loading items:', error);
|
||||
const errorMessage = error?.response?.data?.detail || 'No se pudieron cargar los items de la factura.';
|
||||
toast.error('Error al cargar items', {
|
||||
description: errorMessage
|
||||
});
|
||||
} finally {
|
||||
isLoadingItems = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMoreItems() {
|
||||
const start = 0;
|
||||
const end = currentPage * itemsPerPage;
|
||||
displayedItems = items.slice(start, end);
|
||||
isLoadingMore = false;
|
||||
}
|
||||
|
||||
function handleScroll(e: Event) {
|
||||
const target = e.target as HTMLDivElement;
|
||||
const threshold = 100;
|
||||
const scrolledToBottom = target.scrollHeight - target.scrollTop - target.clientHeight < threshold;
|
||||
|
||||
if (scrolledToBottom && !isLoadingMore && displayedItems.length < items.length) {
|
||||
isLoadingMore = true;
|
||||
currentPage++;
|
||||
loadMoreItems();
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
// Validar que la factura esté guardada (tiene ID)
|
||||
if (!invoice?.id) {
|
||||
toast.warning('Factura no guardada', {
|
||||
description: 'Debes guardar la factura primero antes de agregar partidas.',
|
||||
duration: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isEditMode = false;
|
||||
showItemSheet = true;
|
||||
// Auto-asignar valores desde la factura
|
||||
editingItem = {
|
||||
invoice_id: invoice.id,
|
||||
item_type: invoice.operation_type || '',
|
||||
invoice_number: invoice.invoice_number || '',
|
||||
reference_number: '',
|
||||
order: invoice.purchase_order || '',
|
||||
warehouse: '',
|
||||
location: ''
|
||||
};
|
||||
}
|
||||
|
||||
function handleEdit(item: Item) {
|
||||
isEditMode = true;
|
||||
selectedItem = item;
|
||||
editingItem = { ...item };
|
||||
showItemSheet = true;
|
||||
}
|
||||
|
||||
function handleDelete(item: Item) {
|
||||
selectedItem = item;
|
||||
showDeleteDialog = true;
|
||||
}
|
||||
|
||||
async function saveNewItem() {
|
||||
if (!invoice?.id || !activeCompanyId) return;
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
const response = await itemsApi.create(activeCompanyId, {
|
||||
invoice_id: invoice.id,
|
||||
item_type: editingItem.item_type || '',
|
||||
system_origin: 'SCAF', // Por defecto SCAF, podrías hacerlo configurable
|
||||
invoice_number: editingItem.invoice_number,
|
||||
reference_number: editingItem.reference_number,
|
||||
order: editingItem.order,
|
||||
warehouse: editingItem.warehouse,
|
||||
location: editingItem.location
|
||||
});
|
||||
|
||||
// Recargar items
|
||||
await loadItems();
|
||||
|
||||
showItemSheet = false;
|
||||
toast.success('Item creado', {
|
||||
description: 'El item se ha creado correctamente.'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error creating item:', error);
|
||||
const errorMessage = error?.response?.data?.detail || 'No se pudo crear el item. Intenta de nuevo.';
|
||||
toast.error('Error al crear item', {
|
||||
description: errorMessage
|
||||
});
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEditedItem() {
|
||||
if (!selectedItem?.id || !activeCompanyId) return;
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
await itemsApi.update(selectedItem.id, activeCompanyId, {
|
||||
item_type: editingItem.item_type,
|
||||
system_origin: editingItem.system_origin,
|
||||
invoice_number: editingItem.invoice_number,
|
||||
reference_number: editingItem.reference_number,
|
||||
order: editingItem.order,
|
||||
warehouse: editingItem.warehouse,
|
||||
location: editingItem.location
|
||||
});
|
||||
|
||||
// Recargar items
|
||||
await loadItems();
|
||||
|
||||
showItemSheet = false;
|
||||
toast.success('Item actualizado', {
|
||||
description: 'El item se ha actualizado correctamente.'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error updating item:', error);
|
||||
const errorMessage = error?.response?.data?.detail || 'No se pudo actualizar el item. Intenta de nuevo.';
|
||||
toast.error('Error al actualizar item', {
|
||||
description: errorMessage
|
||||
});
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function saveItem() {
|
||||
if (isEditMode) {
|
||||
saveEditedItem();
|
||||
} else {
|
||||
saveNewItem();
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!selectedItem?.id || !activeCompanyId) return;
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
await itemsApi.delete(selectedItem.id, activeCompanyId);
|
||||
|
||||
// Recargar items
|
||||
await loadItems();
|
||||
|
||||
showDeleteDialog = false;
|
||||
toast.success('Item eliminado', {
|
||||
description: 'El item se ha eliminado correctamente.'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting item:', error);
|
||||
const errorMessage = error?.response?.data?.detail || 'No se pudo eliminar el item. Intenta de nuevo.';
|
||||
toast.error('Error al eliminar item', {
|
||||
description: errorMessage
|
||||
});
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-4 grid-rows-1 gap-3">
|
||||
<div class="border rounded-md p-3 space-y-3 col-span-3">
|
||||
1
|
||||
<div class="border rounded-md p-3 space-y-3 col-span-3">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-sm font-semibold">Items de la Factura</h3>
|
||||
<Button size="sm" onclick={handleAdd}>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
Agregar Item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
bind:this={tableContainer}
|
||||
onscroll={handleScroll}
|
||||
class="max-h-[500px] overflow-auto border rounded-md"
|
||||
>
|
||||
<Table.Root>
|
||||
<Table.Header class="sticky top-0 bg-background z-10">
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[100px]">Tipo</Table.Head>
|
||||
<Table.Head>Factura</Table.Head>
|
||||
<Table.Head>Referencia</Table.Head>
|
||||
<Table.Head>Orden</Table.Head>
|
||||
<Table.Head>Almacén</Table.Head>
|
||||
<Table.Head>Ubicación</Table.Head>
|
||||
<Table.Head class="text-right w-[120px]">Acciones</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if displayedItems.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-center text-muted-foreground py-8">
|
||||
No hay items disponibles
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each displayedItems as item (item.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{item.item_type}</Table.Cell>
|
||||
<Table.Cell>{item.invoice_number || '-'}</Table.Cell>
|
||||
<Table.Cell>{item.reference_number || '-'}</Table.Cell>
|
||||
<Table.Cell>{item.order || '-'}</Table.Cell>
|
||||
<Table.Cell>{item.warehouse || '-'}</Table.Cell>
|
||||
<Table.Cell>{item.location || '-'}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button size="icon" variant="ghost" onclick={() => handleEdit(item)}>
|
||||
<Pencil class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" onclick={() => handleDelete(item)}>
|
||||
<Trash2 class="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{#if isLoadingMore}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={7} class="text-center py-4">
|
||||
<span class="text-sm text-muted-foreground">Cargando más items...</span>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/if}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
{#if items.length > 0}
|
||||
<div class="text-xs text-muted-foreground text-right">
|
||||
Mostrando {displayedItems.length} de {items.length} items
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="border rounded-md p-3 space-y-3 col-start-4">
|
||||
|
||||
<div class="border rounded-md p-3 space-y-3 col-start-4">
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase">Cantidades:</h4>
|
||||
<div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
Partidas: <span class="text-blue-400">{invoice?.items?.length || 0}</span>
|
||||
Partidas: <span class="text-blue-400">{items.length || 0}</span>
|
||||
</div>
|
||||
<div>
|
||||
Bultos: <span class="text-blue-400">{invoice?.packages || 0}</span>
|
||||
Bultos: <span class="text-blue-400">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,13 +331,184 @@
|
||||
</div>
|
||||
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase col-span-2">Valores de importacion:</h4>
|
||||
Dolares: <span class="text-blue-400">{invoice?.items || 0}</span> <span class="text-red-400">USD</span> <br>
|
||||
Pesos: <span class="text-blue-400">{invoice?.items || 0}</span> <span class="text-red-400">MXN</span><br>
|
||||
De Captura: <span class="text-blue-400">{invoice?.items || 0}</span> <span class="text-red-400">USD</span>
|
||||
Dolares: <span class="text-blue-400">0</span> <span class="text-red-400">USD</span> <br>
|
||||
Pesos: <span class="text-blue-400">0</span> <span class="text-red-400">MXN</span><br>
|
||||
De Captura: <span class="text-blue-400">0</span> <span class="text-red-400">USD</span>
|
||||
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase col-span-2 opacity-0">spacer</h4>
|
||||
|
||||
Aduana: <span class="text-blue-400">{invoice?.items || 0}</span> <span class="text-red-400">USD</span><br>
|
||||
Aduana: <span class="text-blue-400">{invoice?.items || 0}</span> <span class="text-red-400">MXN</span><br>
|
||||
Aduana: <span class="text-blue-400">0</span> <span class="text-red-400">USD</span><br>
|
||||
Aduana: <span class="text-blue-400">0</span> <span class="text-red-400">MXN</span><br>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Sheet (Panel lateral para agregar/editar) -->
|
||||
<Sheet.Root bind:open={showItemSheet}>
|
||||
<Sheet.Content side="right" class="w-full sm:max-w-2xl overflow-y-auto">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title>{isEditMode ? 'Editar Item' : 'Agregar Nuevo Item'}</Sheet.Title>
|
||||
<Sheet.Description>
|
||||
{isEditMode ? 'Modifica los campos del item y guarda los cambios.' : 'Completa la información del nuevo item.'}
|
||||
</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
|
||||
<Tabs.Root value="general" class="mt-6">
|
||||
<Tabs.List class="grid w-full grid-cols-4">
|
||||
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||
<Tabs.Trigger value="clasificacion">Clasificación</Tabs.Trigger>
|
||||
<Tabs.Trigger value="cantidades">Cantidades</Tabs.Trigger>
|
||||
<Tabs.Trigger value="otros">Otros</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<!-- Tab: General -->
|
||||
<Tabs.Content value="general" class="space-y-4 mt-4">
|
||||
<!-- Información de la Factura (Solo lectura) -->
|
||||
<div class="rounded-lg border bg-muted/50 p-4 space-y-3">
|
||||
<h4 class="text-sm font-medium">Información de la Factura</h4>
|
||||
{#if !invoice?.id}
|
||||
<div class="text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/20 p-3 rounded">
|
||||
⚠️ Esta factura aún no se ha guardado. Los items se asociarán cuando guardes la factura.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-muted-foreground">ID Factura:</span>
|
||||
<span class="ml-2 font-medium">{invoice.id}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground">Tipo Operación:</span>
|
||||
<span class="ml-2 font-medium uppercase">{invoice.operation_type || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<span class="text-muted-foreground">Número de Factura:</span>
|
||||
<span class="ml-2 font-medium">{invoice.invoice_number || 'Pendiente'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="reference_number">Número de Referencia</Label>
|
||||
<Input id="reference_number" bind:value={editingItem.reference_number} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="order">Orden de Compra/Venta</Label>
|
||||
<Input
|
||||
id="order"
|
||||
bind:value={editingItem.order}
|
||||
placeholder={invoice?.purchase_order || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="warehouse">Almacén</Label>
|
||||
<Input id="warehouse" bind:value={editingItem.warehouse} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="location">Ubicación</Label>
|
||||
<Input id="location" bind:value={editingItem.location} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="invoice_date">Fecha de Factura</Label>
|
||||
<Input
|
||||
id="invoice_date"
|
||||
type="date"
|
||||
bind:value={editingItem.invoice_date}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Tab: Clasificación -->
|
||||
<Tabs.Content value="clasificacion" class="space-y-4 mt-4">
|
||||
<div class="rounded-lg border p-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Aquí puedes agregar campos de clasificación como:
|
||||
</p>
|
||||
<ul class="mt-2 text-sm text-muted-foreground list-disc list-inside">
|
||||
<li>Fracción arancelaria</li>
|
||||
<li>Código de producto</li>
|
||||
<li>Clasificación SCAC</li>
|
||||
<li>Material type</li>
|
||||
<li>Categoría de mercancía</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Tab: Cantidades -->
|
||||
<Tabs.Content value="cantidades" class="space-y-4 mt-4">
|
||||
<div class="rounded-lg border p-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Aquí puedes agregar campos de cantidades como:
|
||||
</p>
|
||||
<ul class="mt-2 text-sm text-muted-foreground list-disc list-inside">
|
||||
<li>Cantidad</li>
|
||||
<li>Unidad de medida</li>
|
||||
<li>Peso neto</li>
|
||||
<li>Peso bruto</li>
|
||||
<li>Valor unitario</li>
|
||||
<li>Valor total</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Tab: Otros -->
|
||||
<Tabs.Content value="otros" class="space-y-4 mt-4">
|
||||
<div class="rounded-lg border p-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Aquí puedes agregar otros campos como:
|
||||
</p>
|
||||
<ul class="mt-2 text-sm text-muted-foreground list-disc list-inside">
|
||||
<li>País de origen</li>
|
||||
<li>Observaciones</li>
|
||||
<li>Documentos adjuntos</li>
|
||||
<li>Información adicional</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
<Sheet.Footer class="mt-6 gap-2">
|
||||
<Button variant="outline" onclick={() => showItemSheet = false} disabled={isSaving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onclick={saveItem} disabled={isSaving}>
|
||||
{#if isSaving}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Guardando...
|
||||
{:else}
|
||||
{isEditMode ? 'Guardar Cambios' : 'Agregar Item'}
|
||||
{/if}
|
||||
</Button>
|
||||
</Sheet.Footer>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<Dialog.Root bind:open={showDeleteDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Confirmar Eliminación</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
¿Está seguro que desea eliminar este item? Esta acción no se puede deshacer.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => showDeleteDialog = false} disabled={isSaving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="destructive" onclick={confirmDelete} disabled={isSaving}>
|
||||
{#if isSaving}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Eliminando...
|
||||
{:else}
|
||||
Eliminar
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { Toaster } from 'svelte-sonner';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
@@ -9,4 +10,5 @@
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<Toaster richColors position="top-right" />
|
||||
{@render children?.()}
|
||||
|
||||
Reference in New Issue
Block a user