feat(shopping): listes projet + déduplication nommage hebdo
Backend : - Migration 007 : list_type VARCHAR(20) sur shopping.lists (weekly/project), url/description/image_url sur shopping.list_items - Modèle ShoppingList : champ list_type - Modèle ListItem : champs url, description, image_url - Schémas : list_type sur Create/Response, nouveaux champs sur ItemCreate/Update/Response - _unique_week_label() : évite les doublons S22 2026 → S22 2026 (2) - finish_shopping : carry-over uniquement pour list_type='weekly' Frontend : - api/shopping.ts : list_type, champs enrichis item, createProjectList() - ProjectItemCard.tsx : carte avec image, description, URL, boutique, cochage - ShoppingPage : · Séparation weekly / project dans la sélection de liste active · Section "Listes projet" sur l'écran vide avec navigation · Badge PROJET dans l'en-tête · Bouton "Clôturer la semaine" et badge "semaine dépassée" masqués sur projet · Bouton "+ Ajouter" (mobile + laptop) sur les listes projet · Vue grille ProjectItemCard pour les listes projet · Modale création liste projet (nom + boutique) · Modale ajout/édition item projet (nom, description, URL, image URL) v0.5.14 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
"""007 - list_type sur shopping.lists, url/description/image_url sur list_items
|
||||||
|
|
||||||
|
Revision ID: 007
|
||||||
|
Revises: 006
|
||||||
|
Create Date: 2026-05-30
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '007'
|
||||||
|
down_revision = '006'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column(
|
||||||
|
'lists',
|
||||||
|
sa.Column('list_type', sa.String(20), nullable=False, server_default='weekly'),
|
||||||
|
schema='shopping',
|
||||||
|
)
|
||||||
|
op.add_column('list_items', sa.Column('url', sa.Text, nullable=True), schema='shopping')
|
||||||
|
op.add_column('list_items', sa.Column('description', sa.Text, nullable=True), schema='shopping')
|
||||||
|
op.add_column('list_items', sa.Column('image_url', sa.String(255), nullable=True), schema='shopping')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column('list_items', 'image_url', schema='shopping')
|
||||||
|
op.drop_column('list_items', 'description', schema='shopping')
|
||||||
|
op.drop_column('list_items', 'url', schema='shopping')
|
||||||
|
op.drop_column('lists', 'list_type', schema='shopping')
|
||||||
@@ -27,6 +27,19 @@ def _iso_week_label() -> str:
|
|||||||
return f"S{iso[1]} {iso[0]}"
|
return f"S{iso[1]} {iso[0]}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _unique_week_label(session: AsyncSession) -> str:
|
||||||
|
base = _iso_week_label()
|
||||||
|
existing = (await session.execute(
|
||||||
|
select(ShoppingList.name).where(ShoppingList.name.like(f"{base}%"))
|
||||||
|
)).scalars().all()
|
||||||
|
if base not in existing:
|
||||||
|
return base
|
||||||
|
counter = 2
|
||||||
|
while f"{base} ({counter})" in existing:
|
||||||
|
counter += 1
|
||||||
|
return f"{base} ({counter})"
|
||||||
|
|
||||||
|
|
||||||
def _item_to_response(item: ListItem) -> ListItemResponse:
|
def _item_to_response(item: ListItem) -> ListItemResponse:
|
||||||
display_name = item.custom_name or (item.product.name if item.product else "Article inconnu")
|
display_name = item.custom_name or (item.product.name if item.product else "Article inconnu")
|
||||||
return ListItemResponse(
|
return ListItemResponse(
|
||||||
@@ -40,6 +53,9 @@ def _item_to_response(item: ListItem) -> ListItemResponse:
|
|||||||
price_recorded=item.price_recorded,
|
price_recorded=item.price_recorded,
|
||||||
carried_over=item.carried_over,
|
carried_over=item.carried_over,
|
||||||
sort_order=item.sort_order,
|
sort_order=item.sort_order,
|
||||||
|
url=item.url,
|
||||||
|
description=item.description,
|
||||||
|
image_url=item.image_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -48,6 +64,7 @@ def _list_to_response(lst: ShoppingList) -> ShoppingListResponse:
|
|||||||
return ShoppingListResponse(
|
return ShoppingListResponse(
|
||||||
id=lst.id,
|
id=lst.id,
|
||||||
name=lst.name,
|
name=lst.name,
|
||||||
|
list_type=lst.list_type,
|
||||||
store_id=lst.store_id,
|
store_id=lst.store_id,
|
||||||
week_date=lst.week_date,
|
week_date=lst.week_date,
|
||||||
status=lst.status,
|
status=lst.status,
|
||||||
@@ -167,8 +184,8 @@ async def create_shopping_list(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
data = payload.model_dump()
|
data = payload.model_dump()
|
||||||
if not data.get('name'):
|
if not data.get('name') and data.get('list_type', 'weekly') == 'weekly':
|
||||||
data['name'] = _iso_week_label()
|
data['name'] = await _unique_week_label(session)
|
||||||
lst = ShoppingList(**data)
|
lst = ShoppingList(**data)
|
||||||
session.add(lst)
|
session.add(lst)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -380,7 +397,7 @@ async def generate_magic_list(session: AsyncSession = Depends(get_session)):
|
|||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
rows = result.mappings().all()
|
rows = result.mappings().all()
|
||||||
|
|
||||||
new_list = ShoppingList(name=_iso_week_label(), status="draft")
|
new_list = ShoppingList(name=await _unique_week_label(session), list_type="weekly", status="draft")
|
||||||
session.add(new_list)
|
session.add(new_list)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
@@ -425,8 +442,13 @@ async def finish_shopping(list_id: uuid.UUID, session: AsyncSession = Depends(ge
|
|||||||
lst.status = "done"
|
lst.status = "done"
|
||||||
|
|
||||||
unchecked = [i for i in lst.items if not i.is_checked]
|
unchecked = [i for i in lst.items if not i.is_checked]
|
||||||
if unchecked:
|
if unchecked and lst.list_type == 'weekly':
|
||||||
new_list = ShoppingList(store_id=lst.store_id, status="draft", name=_iso_week_label())
|
new_list = ShoppingList(
|
||||||
|
store_id=lst.store_id,
|
||||||
|
list_type="weekly",
|
||||||
|
status="draft",
|
||||||
|
name=await _unique_week_label(session),
|
||||||
|
)
|
||||||
session.add(new_list)
|
session.add(new_list)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
for item in unchecked:
|
for item in unchecked:
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class ShoppingList(Base):
|
|||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
name: Mapped[str | None] = mapped_column(String(100))
|
name: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
list_type: Mapped[str] = mapped_column(String(20), server_default="weekly")
|
||||||
store_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.stores.id", ondelete="SET NULL"))
|
store_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.stores.id", ondelete="SET NULL"))
|
||||||
week_date: Mapped[date | None] = mapped_column(Date)
|
week_date: Mapped[date | None] = mapped_column(Date)
|
||||||
status: Mapped[str] = mapped_column(String(20), server_default="draft")
|
status: Mapped[str] = mapped_column(String(20), server_default="draft")
|
||||||
@@ -86,6 +87,9 @@ class ListItem(Base):
|
|||||||
price_recorded: Mapped[Decimal | None] = mapped_column(Numeric(8, 2))
|
price_recorded: Mapped[Decimal | None] = mapped_column(Numeric(8, 2))
|
||||||
carried_over: Mapped[bool] = mapped_column(Boolean, server_default=text("false"))
|
carried_over: Mapped[bool] = mapped_column(Boolean, server_default=text("false"))
|
||||||
sort_order: Mapped[int | None] = mapped_column(Integer)
|
sort_order: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
image_url: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
|
||||||
shopping_list: Mapped["ShoppingList"] = relationship("ShoppingList", back_populates="items")
|
shopping_list: Mapped["ShoppingList"] = relationship("ShoppingList", back_populates="items")
|
||||||
product: Mapped["Product | None"] = relationship("Product", lazy="select")
|
product: Mapped["Product | None"] = relationship("Product", lazy="select")
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ class ListItemCreate(BaseModel):
|
|||||||
custom_name: str | None = None
|
custom_name: str | None = None
|
||||||
quantity: Decimal | None = None
|
quantity: Decimal | None = None
|
||||||
unit: str | None = None
|
unit: str | None = None
|
||||||
|
url: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
image_url: str | None = None
|
||||||
|
|
||||||
@model_validator(mode='after')
|
@model_validator(mode='after')
|
||||||
def must_have_name(self) -> 'ListItemCreate':
|
def must_have_name(self) -> 'ListItemCreate':
|
||||||
@@ -96,6 +99,9 @@ class ListItemUpdate(BaseModel):
|
|||||||
quantity: Decimal | None = None
|
quantity: Decimal | None = None
|
||||||
unit: str | None = None
|
unit: str | None = None
|
||||||
price_recorded: Decimal | None = None
|
price_recorded: Decimal | None = None
|
||||||
|
url: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
image_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ListItemResponse(BaseModel):
|
class ListItemResponse(BaseModel):
|
||||||
@@ -110,13 +116,23 @@ class ListItemResponse(BaseModel):
|
|||||||
price_recorded: Decimal | None
|
price_recorded: Decimal | None
|
||||||
carried_over: bool
|
carried_over: bool
|
||||||
sort_order: int | None
|
sort_order: int | None
|
||||||
|
url: str | None
|
||||||
|
description: str | None
|
||||||
|
image_url: str | None
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListCreate(BaseModel):
|
class ShoppingListCreate(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
list_type: Literal['weekly', 'project'] = 'weekly'
|
||||||
store_id: uuid.UUID | None = None
|
store_id: uuid.UUID | None = None
|
||||||
week_date: date | None = None
|
week_date: date | None = None
|
||||||
|
|
||||||
|
@model_validator(mode='after')
|
||||||
|
def project_requires_name(self) -> 'ShoppingListCreate':
|
||||||
|
if self.list_type == 'project' and not self.name:
|
||||||
|
raise ValueError('Une liste projet doit avoir un nom')
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListUpdate(BaseModel):
|
class ShoppingListUpdate(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
@@ -128,6 +144,7 @@ class ShoppingListResponse(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
name: str | None
|
name: str | None
|
||||||
|
list_type: str
|
||||||
store_id: uuid.UUID | None
|
store_id: uuid.UUID | None
|
||||||
week_date: date | None
|
week_date: date | None
|
||||||
status: str
|
status: str
|
||||||
@@ -140,6 +157,7 @@ class ShoppingListDetailResponse(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
name: str | None
|
name: str | None
|
||||||
|
list_type: str
|
||||||
store_id: uuid.UUID | None
|
store_id: uuid.UUID | None
|
||||||
week_date: date | None
|
week_date: date | None
|
||||||
status: str
|
status: str
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "homehub-frontend",
|
"name": "homehub-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.13",
|
"version": "0.5.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -82,11 +82,15 @@ export interface ShoppingItem {
|
|||||||
price_recorded: string | null
|
price_recorded: string | null
|
||||||
carried_over: boolean
|
carried_over: boolean
|
||||||
sort_order: number | null
|
sort_order: number | null
|
||||||
|
url: string | null
|
||||||
|
description: string | null
|
||||||
|
image_url: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShoppingList {
|
export interface ShoppingList {
|
||||||
id: string
|
id: string
|
||||||
name: string | null
|
name: string | null
|
||||||
|
list_type: 'weekly' | 'project'
|
||||||
store_id: string | null
|
store_id: string | null
|
||||||
week_date: string | null
|
week_date: string | null
|
||||||
status: 'draft' | 'active' | 'done'
|
status: 'draft' | 'active' | 'done'
|
||||||
@@ -101,6 +105,7 @@ export interface ShoppingListDetail extends ShoppingList {
|
|||||||
|
|
||||||
export interface ShoppingListCreate {
|
export interface ShoppingListCreate {
|
||||||
name?: string
|
name?: string
|
||||||
|
list_type?: 'weekly' | 'project'
|
||||||
store_id?: string
|
store_id?: string
|
||||||
week_date?: string
|
week_date?: string
|
||||||
}
|
}
|
||||||
@@ -116,6 +121,9 @@ export interface ShoppingItemCreate {
|
|||||||
custom_name?: string
|
custom_name?: string
|
||||||
quantity?: string
|
quantity?: string
|
||||||
unit?: string
|
unit?: string
|
||||||
|
url?: string
|
||||||
|
description?: string
|
||||||
|
image_url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShoppingItemUpdate {
|
export interface ShoppingItemUpdate {
|
||||||
@@ -123,6 +131,9 @@ export interface ShoppingItemUpdate {
|
|||||||
quantity?: string
|
quantity?: string
|
||||||
unit?: string
|
unit?: string
|
||||||
price_recorded?: string
|
price_recorded?: string
|
||||||
|
url?: string
|
||||||
|
description?: string
|
||||||
|
image_url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE = '/api/shopping'
|
const BASE = '/api/shopping'
|
||||||
@@ -201,6 +212,10 @@ export async function createList(data: ShoppingListCreate): Promise<ShoppingList
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createProjectList(name: string, storeId?: string): Promise<ShoppingListDetail> {
|
||||||
|
return createList({ name, list_type: 'project', store_id: storeId })
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchListDetail(id: string): Promise<ShoppingListDetail> {
|
export async function fetchListDetail(id: string): Promise<ShoppingListDetail> {
|
||||||
return handleResponse(await fetch(`${BASE}/lists/${id}`))
|
return handleResponse(await fetch(`${BASE}/lists/${id}`))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { ShoppingItem, Store } from '../../api/shopping'
|
||||||
|
|
||||||
|
interface ProjectItemCardProps {
|
||||||
|
item: ShoppingItem
|
||||||
|
stores: Store[]
|
||||||
|
onCheck: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
onEdit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectItemCard({ item, stores, onCheck, onDelete, onEdit }: ProjectItemCardProps) {
|
||||||
|
const [imgError, setImgError] = useState(false)
|
||||||
|
const store = stores.find(s => s.id === item.product_id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: item.is_checked ? 'rgba(142,192,124,0.08)' : 'var(--bg-3)',
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid var(--bg-4)',
|
||||||
|
opacity: item.is_checked ? 0.65 : 1,
|
||||||
|
transition: 'opacity 0.2s',
|
||||||
|
}}>
|
||||||
|
{/* Image */}
|
||||||
|
{item.image_url && !imgError && (
|
||||||
|
<div style={{ position: 'relative', height: 140, overflow: 'hidden', background: 'var(--bg-2)' }}>
|
||||||
|
<img
|
||||||
|
src={item.image_url}
|
||||||
|
alt={item.display_name}
|
||||||
|
onError={() => setImgError(true)}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{/* Nom + actions */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={onCheck}
|
||||||
|
style={{
|
||||||
|
width: 24, height: 24, borderRadius: '50%', flexShrink: 0,
|
||||||
|
border: `2px solid ${item.is_checked ? 'var(--ok)' : 'var(--bg-5)'}`,
|
||||||
|
background: item.is_checked ? 'var(--ok)' : 'transparent',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'pointer', transition: 'all 0.15s', marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.is_checked && <span style={{ color: '#1d2021', fontSize: 13, fontWeight: 700 }}>✓</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
color: item.is_checked ? 'var(--ink-3)' : 'var(--ink-1)',
|
||||||
|
fontFamily: 'var(--font-ui)', fontSize: 15, fontWeight: 600,
|
||||||
|
textDecoration: item.is_checked ? 'line-through' : 'none',
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
}}>
|
||||||
|
{item.display_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
style={{ background: 'var(--bg-4)', border: 'none', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', color: 'var(--ink-3)', fontSize: 13 }}
|
||||||
|
title="Modifier"
|
||||||
|
><i className="fa-solid fa-pen" /></button>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
style={{ background: 'transparent', border: 'none', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', color: 'var(--ink-4)', fontSize: 13 }}
|
||||||
|
title="Supprimer"
|
||||||
|
><i className="fa-solid fa-xmark" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{item.description && (
|
||||||
|
<p style={{
|
||||||
|
margin: 0, color: 'var(--ink-2)', fontFamily: 'var(--font-ui)',
|
||||||
|
fontSize: 13, lineHeight: 1.5, overflowWrap: 'anywhere',
|
||||||
|
}}>
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Meta : boutique + lien */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
{store && (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
background: 'var(--bg-4)', borderRadius: 999, padding: '2px 8px',
|
||||||
|
color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-ui)',
|
||||||
|
}}>
|
||||||
|
<i className="fa-solid fa-store" style={{ fontSize: 9 }} />
|
||||||
|
{store.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.url && (
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||||
|
color: 'var(--info)', fontFamily: 'var(--font-ui)', fontSize: 12,
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-arrow-up-right-from-square" style={{ fontSize: 10 }} />
|
||||||
|
Voir le produit
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{item.quantity && (
|
||||||
|
<span style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||||
|
{item.quantity}{item.unit ? ` ${item.unit}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,13 +4,14 @@ import { useServerEvents } from '../hooks/useServerEvents'
|
|||||||
import { matchesSearch } from '../utils/search'
|
import { matchesSearch } from '../utils/search'
|
||||||
import type { ShoppingListDetail, ShoppingList, Store, Product, ShoppingItem } from '../api/shopping'
|
import type { ShoppingListDetail, ShoppingList, Store, Product, ShoppingItem } from '../api/shopping'
|
||||||
import {
|
import {
|
||||||
fetchLists, createList, fetchListDetail, deleteList,
|
fetchLists, createList, createProjectList, fetchListDetail, deleteList,
|
||||||
addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList,
|
addItem, updateItem, deleteItem, finishShopping, fetchStores, generateMagicList,
|
||||||
searchProducts, createProduct,
|
searchProducts, createProduct,
|
||||||
} from '../api/shopping'
|
} from '../api/shopping'
|
||||||
import Modal from '../components/Modal'
|
import Modal from '../components/Modal'
|
||||||
import BottomSheet from '../components/BottomSheet'
|
import BottomSheet from '../components/BottomSheet'
|
||||||
import ItemRow from '../components/shopping/ItemRow'
|
import ItemRow from '../components/shopping/ItemRow'
|
||||||
|
import ProjectItemCard from '../components/shopping/ProjectItemCard'
|
||||||
import CatalogueModal from '../components/shopping/CatalogueModal'
|
import CatalogueModal from '../components/shopping/CatalogueModal'
|
||||||
import BoutiquesModal from '../components/shopping/BoutiquesModal'
|
import BoutiquesModal from '../components/shopping/BoutiquesModal'
|
||||||
import { useWakeLock } from '../hooks/useWakeLock'
|
import { useWakeLock } from '../hooks/useWakeLock'
|
||||||
@@ -132,7 +133,8 @@ export default function ShoppingPage() {
|
|||||||
setStores(storesData)
|
setStores(storesData)
|
||||||
setProducts([...productsData].sort((a, b) => a.name.localeCompare(b.name, 'fr')))
|
setProducts([...productsData].sort((a, b) => a.name.localeCompare(b.name, 'fr')))
|
||||||
|
|
||||||
const current = listsData.find(l => l.status === 'draft' || l.status === 'active')
|
const current = listsData.find(l => (l.status === 'draft' || l.status === 'active') && l.list_type === 'weekly')
|
||||||
|
?? listsData.find(l => (l.status === 'draft' || l.status === 'active') && l.list_type === 'project')
|
||||||
if (current) {
|
if (current) {
|
||||||
setCurrentList(await fetchListDetail(current.id))
|
setCurrentList(await fetchListDetail(current.id))
|
||||||
} else {
|
} else {
|
||||||
@@ -353,6 +355,81 @@ export default function ShoppingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [showFinishConfirm, setShowFinishConfirm] = useState(false)
|
const [showFinishConfirm, setShowFinishConfirm] = useState(false)
|
||||||
|
const [showNewProjectModal, setShowNewProjectModal] = useState(false)
|
||||||
|
const [newProjectName, setNewProjectName] = useState('')
|
||||||
|
const [newProjectStoreId, setNewProjectStoreId] = useState('')
|
||||||
|
const [projectCreating, setProjectCreating] = useState(false)
|
||||||
|
const [showProjectItemModal, setShowProjectItemModal] = useState(false)
|
||||||
|
const [editingProjectItem, setEditingProjectItem] = useState<ShoppingItem | null>(null)
|
||||||
|
const [projItemName, setProjItemName] = useState('')
|
||||||
|
const [projItemDesc, setProjItemDesc] = useState('')
|
||||||
|
const [projItemUrl, setProjItemUrl] = useState('')
|
||||||
|
const [projItemImageUrl, setProjItemImageUrl] = useState('')
|
||||||
|
const [projItemStoreId, setProjItemStoreId] = useState('')
|
||||||
|
const [projItemSaving, setProjItemSaving] = useState(false)
|
||||||
|
|
||||||
|
async function handleCreateProject() {
|
||||||
|
if (!newProjectName.trim()) return
|
||||||
|
setProjectCreating(true)
|
||||||
|
try {
|
||||||
|
const detail = await createProjectList(newProjectName.trim(), newProjectStoreId || undefined)
|
||||||
|
setCurrentList(detail)
|
||||||
|
setShowNewProjectModal(false)
|
||||||
|
setNewProjectName('')
|
||||||
|
setNewProjectStoreId('')
|
||||||
|
void loadData()
|
||||||
|
} catch {
|
||||||
|
setError('Erreur lors de la création')
|
||||||
|
} finally {
|
||||||
|
setProjectCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openProjectItemModal(item?: ShoppingItem) {
|
||||||
|
if (item) {
|
||||||
|
setEditingProjectItem(item)
|
||||||
|
setProjItemName(item.display_name)
|
||||||
|
setProjItemDesc(item.description ?? '')
|
||||||
|
setProjItemUrl(item.url ?? '')
|
||||||
|
setProjItemImageUrl(item.image_url ?? '')
|
||||||
|
setProjItemStoreId('')
|
||||||
|
} else {
|
||||||
|
setEditingProjectItem(null)
|
||||||
|
setProjItemName('')
|
||||||
|
setProjItemDesc('')
|
||||||
|
setProjItemUrl('')
|
||||||
|
setProjItemImageUrl('')
|
||||||
|
setProjItemStoreId('')
|
||||||
|
}
|
||||||
|
setShowProjectItemModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveProjectItem() {
|
||||||
|
if (!currentList || !projItemName.trim()) return
|
||||||
|
setProjItemSaving(true)
|
||||||
|
try {
|
||||||
|
if (editingProjectItem) {
|
||||||
|
await updateItem(currentList.id, editingProjectItem.id, {
|
||||||
|
url: projItemUrl.trim() || undefined,
|
||||||
|
description: projItemDesc.trim() || undefined,
|
||||||
|
image_url: projItemImageUrl.trim() || undefined,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await addItem(currentList.id, {
|
||||||
|
custom_name: projItemName.trim(),
|
||||||
|
description: projItemDesc.trim() || undefined,
|
||||||
|
url: projItemUrl.trim() || undefined,
|
||||||
|
image_url: projItemImageUrl.trim() || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setShowProjectItemModal(false)
|
||||||
|
void refreshCurrentList()
|
||||||
|
} catch {
|
||||||
|
setError("Erreur lors de l'enregistrement")
|
||||||
|
} finally {
|
||||||
|
setProjItemSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFinish() {
|
async function handleFinish() {
|
||||||
if (!currentList) return
|
if (!currentList) return
|
||||||
@@ -397,7 +474,9 @@ export default function ShoppingPage() {
|
|||||||
const uncheckedItems = sortedItems.filter(i => !i.is_checked)
|
const uncheckedItems = sortedItems.filter(i => !i.is_checked)
|
||||||
const checkedItems = sortedItems.filter(i => i.is_checked)
|
const checkedItems = sortedItems.filter(i => i.is_checked)
|
||||||
const hasCurrentList = currentList !== null
|
const hasCurrentList = currentList !== null
|
||||||
const pastLists = allLists.filter(l => l.status === 'done')
|
const isProjectList = currentList?.list_type === 'project'
|
||||||
|
const pastLists = allLists.filter(l => l.status === 'done' && l.list_type === 'weekly')
|
||||||
|
const activeProjectLists = allLists.filter(l => (l.status === 'draft' || l.status === 'active') && l.list_type === 'project')
|
||||||
|
|
||||||
const filteredProducts = products.filter(p => {
|
const filteredProducts = products.filter(p => {
|
||||||
const term = itemSearch.trim()
|
const term = itemSearch.trim()
|
||||||
@@ -418,8 +497,13 @@ export default function ShoppingPage() {
|
|||||||
borderBottom: '1px solid var(--bg-4)',
|
borderBottom: '1px solid var(--bg-4)',
|
||||||
position: 'sticky', top: 0, zIndex: 10,
|
position: 'sticky', top: 0, zIndex: 10,
|
||||||
}}>
|
}}>
|
||||||
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1, fontSize: 18, ...noSelect }}>
|
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1, fontSize: 18, ...noSelect, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
{hasCurrentList ? (currentList.name ?? 'Courses') : 'Courses'}
|
{hasCurrentList ? (currentList.name ?? 'Courses') : 'Courses'}
|
||||||
|
{isProjectList && (
|
||||||
|
<span style={{ fontSize: 10, background: 'var(--info)', color: '#fff', borderRadius: 999, padding: '2px 7px', fontFamily: 'var(--font-ui)', fontWeight: 600, letterSpacing: 0.3 }}>
|
||||||
|
PROJET
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCatalogueModal(true)}
|
onClick={() => setShowCatalogueModal(true)}
|
||||||
@@ -439,7 +523,7 @@ export default function ShoppingPage() {
|
|||||||
...noSelect,
|
...noSelect,
|
||||||
}}
|
}}
|
||||||
>Boutiques</button>
|
>Boutiques</button>
|
||||||
{hasCurrentList && (
|
{hasCurrentList && !isProjectList && (
|
||||||
<button
|
<button
|
||||||
className="hidden lg:flex"
|
className="hidden lg:flex"
|
||||||
onClick={openAddSheet}
|
onClick={openAddSheet}
|
||||||
@@ -454,6 +538,21 @@ export default function ShoppingPage() {
|
|||||||
<i className="fa-solid fa-cart-plus" /> Article
|
<i className="fa-solid fa-cart-plus" /> Article
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{hasCurrentList && isProjectList && (
|
||||||
|
<button
|
||||||
|
className="hidden lg:flex"
|
||||||
|
onClick={() => openProjectItemModal()}
|
||||||
|
style={{
|
||||||
|
alignItems: 'center', gap: 8,
|
||||||
|
background: 'var(--accent)', border: 'none',
|
||||||
|
borderRadius: 8, color: '#1d2021', cursor: 'pointer',
|
||||||
|
padding: '6px 14px', fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600, minHeight: 36,
|
||||||
|
...noSelect,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-plus" /> Ajouter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Erreur ── */}
|
{/* ── Erreur ── */}
|
||||||
@@ -520,6 +619,38 @@ export default function ShoppingPage() {
|
|||||||
}}
|
}}
|
||||||
>Voir l'historique ({pastLists.length})</button>
|
>Voir l'historique ({pastLists.length})</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Séparateur + listes projet */}
|
||||||
|
<div style={{ width: '100%', borderTop: '1px solid var(--bg-4)', paddingTop: 8 }} />
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', maxWidth: 400 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: 'var(--ink-3)', fontFamily: 'var(--font-ui)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.5, ...noSelect }}>
|
||||||
|
Listes projet
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewProjectModal(true)}
|
||||||
|
style={{ background: 'var(--bg-3)', border: '1px solid var(--bg-5)', borderRadius: 8, color: 'var(--ink-2)', cursor: 'pointer', padding: '4px 10px', fontFamily: 'var(--font-ui)', fontSize: 12, ...noSelect }}
|
||||||
|
>+ Nouveau projet</button>
|
||||||
|
</div>
|
||||||
|
{activeProjectLists.length === 0 && (
|
||||||
|
<p style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-ui)', fontSize: 13, margin: 0, textAlign: 'center', ...noSelect }}>Aucun projet en cours</p>
|
||||||
|
)}
|
||||||
|
{activeProjectLists.map(list => (
|
||||||
|
<div
|
||||||
|
key={list.id}
|
||||||
|
onClick={() => void fetchListDetail(list.id).then(d => { setCurrentList(d) })}
|
||||||
|
className="glass interactive"
|
||||||
|
style={{ borderRadius: 8, padding: '10px 14px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10 }}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-bag-shopping" style={{ color: 'var(--info)', fontSize: 14, flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14, ...noSelect }}>{list.name}</div>
|
||||||
|
<div style={{ color: 'var(--ink-4)', fontSize: 11, fontFamily: 'var(--font-mono)', ...noSelect }}>{list.item_count} article{list.item_count > 1 ? 's' : ''}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: 'var(--ink-3)', fontSize: 16 }}>→</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -537,7 +668,7 @@ export default function ShoppingPage() {
|
|||||||
<span style={{ color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', fontSize: 12, ...noSelect }}>
|
<span style={{ color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', fontSize: 12, ...noSelect }}>
|
||||||
{checkedItems.length}/{currentList.item_count} cochés
|
{checkedItems.length}/{currentList.item_count} cochés
|
||||||
</span>
|
</span>
|
||||||
{isListOutdated(currentList.name) && (
|
{!isProjectList && isListOutdated(currentList.name) && (
|
||||||
<span
|
<span
|
||||||
title="La semaine ISO de cette liste est dépassée — pense à clôturer"
|
title="La semaine ISO de cette liste est dépassée — pense à clôturer"
|
||||||
style={{
|
style={{
|
||||||
@@ -563,6 +694,7 @@ export default function ShoppingPage() {
|
|||||||
onClick={() => void handleDeleteCurrentList()}
|
onClick={() => void handleDeleteCurrentList()}
|
||||||
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 8px', ...noSelect }}
|
style={{ background: 'transparent', border: 'none', color: 'var(--err)', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontSize: 12, padding: '4px 8px', ...noSelect }}
|
||||||
>Supprimer</button>
|
>Supprimer</button>
|
||||||
|
{!isProjectList && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFinishConfirm(true)}
|
onClick={() => setShowFinishConfirm(true)}
|
||||||
style={{
|
style={{
|
||||||
@@ -575,6 +707,22 @@ export default function ShoppingPage() {
|
|||||||
>
|
>
|
||||||
<i className="fa-solid fa-check" /> Clôturer la semaine
|
<i className="fa-solid fa-check" /> Clôturer la semaine
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{isProjectList && (
|
||||||
|
<button
|
||||||
|
onClick={() => openProjectItemModal()}
|
||||||
|
className="flex lg:hidden"
|
||||||
|
style={{
|
||||||
|
background: 'var(--accent)', color: '#1d2021', border: 'none',
|
||||||
|
borderRadius: 8, padding: '6px 14px',
|
||||||
|
fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 13,
|
||||||
|
cursor: 'pointer', minHeight: 36, ...noSelect,
|
||||||
|
alignItems: 'center', gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-plus" /> Ajouter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Articles non cochés */}
|
{/* Articles non cochés */}
|
||||||
@@ -584,8 +732,22 @@ export default function ShoppingPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', paddingBottom: 64 }}>
|
<div style={{ flex: 1, overflowY: 'auto', paddingBottom: 64, padding: isProjectList ? '12px 16px 64px' : '0 0 64px' }}>
|
||||||
{uncheckedItems.map(item => (
|
{isProjectList ? (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||||
|
{sortedItems.map(item => (
|
||||||
|
<ProjectItemCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
stores={stores}
|
||||||
|
onCheck={() => void handleCheckItem(item.id, !item.is_checked)}
|
||||||
|
onDelete={() => void handleDeleteItem(item.id)}
|
||||||
|
onEdit={() => openProjectItemModal(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
uncheckedItems.map(item => (
|
||||||
<ItemRow
|
<ItemRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -594,7 +756,8 @@ export default function ShoppingPage() {
|
|||||||
onEdit={() => openEditItem(item)}
|
onEdit={() => openEditItem(item)}
|
||||||
storeMode
|
storeMode
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
{checkedItems.length > 0 && (
|
{checkedItems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -886,6 +1049,94 @@ export default function ShoppingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modale création liste projet */}
|
||||||
|
{showNewProjectModal && (
|
||||||
|
<Modal title="Nouveau projet d'achat" onClose={() => setShowNewProjectModal(false)} width={420}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Nom du projet (ex: RAM pour PC)"
|
||||||
|
value={newProjectName}
|
||||||
|
onChange={e => setNewProjectName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={e => e.key === 'Enter' && void handleCreateProject()}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
style={inputStyle}
|
||||||
|
value={newProjectStoreId}
|
||||||
|
onChange={e => setNewProjectStoreId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Boutique (optionnel)</option>
|
||||||
|
{stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewProjectModal(false)}
|
||||||
|
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 44 }}
|
||||||
|
>Annuler</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleCreateProject()}
|
||||||
|
disabled={!newProjectName.trim() || projectCreating}
|
||||||
|
style={{ padding: '10px 18px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 700, minHeight: 44, opacity: projectCreating ? 0.7 : 1 }}
|
||||||
|
>{projectCreating ? '…' : 'Créer'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modale ajout/édition item projet */}
|
||||||
|
{showProjectItemModal && (
|
||||||
|
<Modal
|
||||||
|
title={editingProjectItem ? `Modifier — ${editingProjectItem.display_name}` : 'Ajouter un article'}
|
||||||
|
onClose={() => setShowProjectItemModal(false)}
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{!editingProjectItem && (
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Nom de l'article *"
|
||||||
|
value={projItemName}
|
||||||
|
onChange={e => setProjItemName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
style={{ ...inputStyle, minHeight: 72, resize: 'vertical' }}
|
||||||
|
placeholder="Description (optionnel)"
|
||||||
|
value={projItemDesc}
|
||||||
|
onChange={e => setProjItemDesc(e.target.value)}
|
||||||
|
autoFocus={!!editingProjectItem}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Lien URL (ex: https://amazon.fr/...)"
|
||||||
|
value={projItemUrl}
|
||||||
|
onChange={e => setProjItemUrl(e.target.value)}
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Image URL (ex: https://.../.jpg)"
|
||||||
|
value={projItemImageUrl}
|
||||||
|
onChange={e => setProjItemImageUrl(e.target.value)}
|
||||||
|
type="url"
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowProjectItemModal(false)}
|
||||||
|
style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 44 }}
|
||||||
|
>Annuler</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleSaveProjectItem()}
|
||||||
|
disabled={(!editingProjectItem && !projItemName.trim()) || projItemSaving}
|
||||||
|
style={{ padding: '10px 18px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 700, minHeight: 44, opacity: projItemSaving ? 0.7 : 1 }}
|
||||||
|
>{projItemSaving ? '…' : 'Enregistrer'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user