Ver Fonte

feat: enhance material management, fix navigation scroll, and add admin order deletion

- Added long_desc fields to materials (backend, DB, and Admin UI)
- Integrated long descriptions into the main site Materials section
- Fixed smooth scroll offset for landing page sections to clear fixed header
- Switched navigation to RouterLink for reliable hash navigation
- Fixed bug where material 'is_active' status wasn't populating in edit modal
- Added functionality for admins to permanently delete orders and associated files/data
unknown há 3 dias atrás
pai
commit
f5e8ba4313
44 ficheiros alterados com 2877 adições e 1920 exclusões
  1. 10 0
      backend/config.py
  2. 3 3
      backend/mytest_gen_uplat.py
  3. BIN
      backend/requirements.txt
  4. 4 4
      backend/routers/catalog.py
  5. 5 0
      backend/routers/chat.py
  6. 193 28
      backend/routers/orders.py
  7. 15 0
      backend/schemas.py
  8. 10 0
      backend/scratch/check_messages.py
  9. 33 0
      backend/scratch/create_mock_orders.py
  10. 37 0
      backend/scratch/test_pdf.py
  11. 11 0
      backend/scratch/verify_pagination.py
  12. 11 1
      backend/services/audit_service.py
  13. 9 0
      backend/services/event_hooks.py
  14. 80 0
      backend/services/fiscal_service.py
  15. 25 6
      backend/services/global_manager.py
  16. 276 0
      backend/services/invoice_service.py
  17. 0 240
      backend/services/uplatnica_generator.py
  18. 40 38
      scripts/manage_locales.py
  19. 7 6
      src/components/Footer.vue
  20. 18 16
      src/components/Header.vue
  21. 11 2
      src/components/LanguageSwitcher.vue
  22. 7 7
      src/components/OrderTracker.vue
  23. 2 2
      src/components/PrintingNuancesSection.vue
  24. 1 1
      src/components/ProcessSection.vue
  25. 1 1
      src/components/QuotingSection.vue
  26. 12 7
      src/components/ServicesSection.vue
  27. 17 1
      src/i18n.ts
  28. 46 6
      src/lib/api.ts
  29. 141 0
      src/locales/en.admin.json
  30. 33 157
      src/locales/en.json
  31. 141 0
      src/locales/me.admin.json
  32. 33 157
      src/locales/me.json
  33. 141 0
      src/locales/ru.admin.json
  34. 33 157
      src/locales/ru.json
  35. 756 0
      src/locales/translations.admin.json
  36. 78 822
      src/locales/translations.user.json
  37. 141 0
      src/locales/ua.admin.json
  38. 34 158
      src/locales/ua.json
  39. 2 0
      src/main.ts
  40. 328 62
      src/pages/Admin.vue
  41. 67 11
      src/pages/Orders.vue
  42. 1 1
      src/pages/Portfolio.vue
  43. 52 26
      src/router/index.ts
  44. 12 0
      src/stores/auth.ts

+ 10 - 0
backend/config.py

@@ -33,5 +33,15 @@ for d in [UPLOAD_DIR, PREVIEW_DIR]:
 ZIRO_RACUN = "510-1234567890123-45"
 COMPANY_NAME = "RADIONICA 3D"
 COMPANY_PIB = "01234567"
+COMPANY_CITY = "Podgorica"
 COMPANY_ADDRESS = "Cetinjski Put, Podgorica, Montenegro"
 PDV_RATE = 21 # In percent
+
+# EFI Fiskalizacija
+EFI_ENABLED = False
+EFI_CERT_PATH = os.path.join(BASE_DIR, "cert.p12")
+EFI_CERT_PASS = "changeit"
+EFI_ENU_CODE = "xx123yy456" # Electronic Fiscal Device code
+EFI_BUS_UNIT = "br123"      # Business Unit code
+EFI_OPERATOR = "op123"      # Operator code
+EFI_STAGING = True          # Use test environment

+ 3 - 3
backend/mytest_gen_uplat.py

@@ -1,5 +1,5 @@
 import os
-from services.uplatnica_generator import generate_uplatnica2
+from services.uplatnica_generator import generate_uplatnica
 
 # Set up test data
 order_id = 1234567890
@@ -8,7 +8,7 @@ payer_address = "123 Main St, Anytown USA"
 amount = 101.00
 
 # Generate the invoice PDF
-pdf_path = generate_uplatnica2(order_id, payer_name, payer_address, amount)
-
+pdf_path = generate_uplatnica(order_id, payer_name, payer_address, amount)
+pdf_path2 = genera
 # Test that the PDF file is created and saved to the correct location
 assert os.path.exists(pdf_path)

BIN
backend/requirements.txt


+ 4 - 4
backend/routers/catalog.py

@@ -20,7 +20,7 @@ async def get_materials():
 async def get_services():
     return db.execute_query("SELECT * FROM services WHERE is_active = TRUE")
 
-@router.get("/admin/materials")
+@router.get("/admin/materials", response_model=List[schemas.MaterialBase])
 async def admin_get_materials(token: str = Depends(auth_utils.oauth2_scheme)):
     payload = auth_utils.decode_token(token)
     if not payload or payload.get("role") != 'admin':
@@ -38,8 +38,8 @@ async def admin_create_material(data: schemas.MaterialCreate, token: str = Depen
     if not payload or payload.get("role") != 'admin':
         raise HTTPException(status_code=403, detail="Admin role required")
     colors_json = json.dumps(data.available_colors) if data.available_colors else None
-    query = "INSERT INTO materials (name_en, name_ru, name_ua, name_me, desc_en, desc_ru, desc_ua, desc_me, price_per_cm3, available_colors, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
-    params = (data.name_en, data.name_ru, data.name_ua, data.name_me, data.desc_en, data.desc_ru, data.desc_ua, data.desc_me, data.price_per_cm3, colors_json, data.is_active)
+    query = "INSERT INTO materials (name_en, name_ru, name_ua, name_me, desc_en, desc_ru, desc_ua, desc_me, long_desc_en, long_desc_ru, long_desc_ua, long_desc_me, price_per_cm3, available_colors, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
+    params = (data.name_en, data.name_ru, data.name_ua, data.name_me, data.desc_en, data.desc_ru, data.desc_ua, data.desc_me, data.long_desc_en, data.long_desc_ru, data.long_desc_ua, data.long_desc_me, data.price_per_cm3, colors_json, data.is_active)
     mat_id = db.execute_commit(query, params)
     return {"id": mat_id}
 
@@ -70,7 +70,7 @@ async def admin_delete_material(mat_id: int, token: str = Depends(auth_utils.oau
     db.execute_commit("DELETE FROM materials WHERE id = %s", (mat_id,))
     return {"id": mat_id, "status": "deleted"}
 
-@router.get("/admin/services")
+@router.get("/admin/services", response_model=List[schemas.ServiceBase])
 async def admin_get_services(token: str = Depends(auth_utils.oauth2_scheme)):
     payload = auth_utils.decode_token(token)
     if not payload or payload.get("role") != 'admin':

+ 5 - 0
backend/routers/chat.py

@@ -36,6 +36,7 @@ async def get_order_messages(order_id: int, token: str = Depends(auth_utils.oaut
     if role == 'admin':
         db.execute_commit("UPDATE order_messages SET is_read = TRUE WHERE order_id = %s AND is_from_admin = FALSE AND is_read = FALSE", (order_id,))
         await global_manager.notify_admins()
+        await global_manager.notify_order_read(order_id)
     else:
         db.execute_commit("UPDATE order_messages SET is_read = TRUE WHERE order_id = %s AND is_from_admin = TRUE AND is_read = FALSE", (order_id,))
         await global_manager.notify_user(user_id)
@@ -78,7 +79,10 @@ async def post_order_message(order_id: int, data: schemas.MessageCreate, token:
     if is_admin:
         await global_manager.notify_user(order[0]['user_id'])
     else:
+        from services import event_hooks
         await global_manager.notify_admins()
+        await global_manager.notify_admins_new_message(order_id, message)
+        event_hooks.on_message_received(order_id, user_id, message)
     return {"id": msg_id, "status": "sent"}
 
 @router.websocket("/ws/chat/{order_id}")
@@ -114,6 +118,7 @@ async def ws_chat(websocket: WebSocket, order_id: int, token: str = Query(...)):
                 if role == 'admin':
                     db.execute_commit("UPDATE order_messages SET is_read = TRUE WHERE order_id = %s AND is_from_admin = FALSE AND is_read = FALSE", (order_id,))
                     await global_manager.notify_admins()
+                    await global_manager.notify_order_read(order_id)
                 else:
                     db.execute_commit("UPDATE order_messages SET is_read = TRUE WHERE order_id = %s AND is_from_admin = TRUE AND is_read = FALSE", (order_id,))
                     await global_manager.notify_user(user_id)

+ 193 - 28
backend/routers/orders.py

@@ -14,6 +14,8 @@ import slicer_utils
 from fastapi import APIRouter, Request, Form, Depends, HTTPException, BackgroundTasks, UploadFile, File
 from services import pricing, order_processing, event_hooks
 from services.audit_service import audit_service
+from pydantic import BaseModel
+from datetime import datetime
 
 router = APIRouter(prefix="/orders", tags=["orders"])
 
@@ -105,6 +107,14 @@ async def create_order(
                 qty = parsed_quantities[idx] if idx < len(parsed_quantities) else 1
                 unit_p = item_prices[idx] if idx < len(item_prices) else 0.0
                 db.execute_commit("UPDATE order_files SET order_id = %s, quantity = %s, unit_price = %s WHERE id = %s", (order_insert_id, qty, unit_p, f_id))
+                
+                # Create corresponding fixed order item
+                file_row = db.execute_query("SELECT filename FROM order_files WHERE id = %s", (f_id,))
+                fname = file_row[0]['filename'] if file_row else f"File #{f_id}"
+                db.execute_commit(
+                    "INSERT INTO order_items (order_id, description, quantity, unit_price, total_price) VALUES (%s, %s, %s, %s, %s)",
+                    (order_insert_id, f"3D Print: {fname}", qty, unit_p, round(qty * unit_p, 2))
+                )
         background_tasks.add_task(order_processing.process_order_slicing, order_insert_id)
         background_tasks.add_task(event_hooks.on_order_created, order_insert_id)
         return {"status": "success", "order_id": order_insert_id, "message": "Order submitted successfully"}
@@ -113,29 +123,47 @@ async def create_order(
         raise HTTPException(status_code=500, detail="Internal server error occurred while processing order")
 
 @router.get("/my")
-async def get_my_orders(token: str = Depends(auth_utils.oauth2_scheme)):
+async def get_my_orders(
+    page: int = 1,
+    size: int = 10,
+    token: str = Depends(auth_utils.oauth2_scheme)
+):
     payload = auth_utils.decode_token(token)
     if not payload:
         raise HTTPException(status_code=401, detail="Invalid token")
     
     user_id = payload.get("id")
+    offset = (page - 1) * size
+
+    # Get total count
+    count_query = "SELECT COUNT(*) as total FROM orders WHERE user_id = %s"
+    count_res = db.execute_query(count_query, (user_id,))
+    total = count_res[0]['total'] if count_res else 0
+
     query = """
     SELECT o.*, 
            (SELECT count(*) FROM order_messages om WHERE om.order_id = o.id AND om.is_from_admin = TRUE AND om.is_read = FALSE) as unread_count,
-           GROUP_CONCAT(JSON_OBJECT('file_id', f.id, 'filename', f.filename, 'file_path', f.file_path, 'quantity', f.quantity, 'preview_path', f.preview_path, 'print_time', f.print_time, 'filament_g', f.filament_g)) as files
+           GROUP_CONCAT(IF(f.id IS NOT NULL, JSON_OBJECT('id', f.id, 'filename', f.filename, 'file_path', f.file_path, 'quantity', f.quantity, 'preview_path', f.preview_path, 'print_time', f.print_time, 'filament_g', f.filament_g), NULL)) as files
     FROM orders o
     LEFT JOIN order_files f ON o.id = f.order_id
     WHERE o.user_id = %s
     GROUP BY o.id
-    ORDER BY o.created_at DESC
+    ORDER BY 
+      CASE 
+        WHEN status IN ('pending', 'processing', 'shipped') THEN 0
+        ELSE 1
+      END ASC,
+      o.created_at DESC
+    LIMIT %s OFFSET %s
     """
-    results = db.execute_query(query, (user_id,))
+    results = db.execute_query(query, (user_id, size, offset))
     for row in results:
         if row['files']:
             try: row['files'] = json.loads(f"[{row['files']}]")
             except: row['files'] = []
         else: row['files'] = []
-    return results
+        row['items'] = db.execute_query("SELECT * FROM order_items WHERE order_id = %s", (row['id'],))
+    return {"orders": results, "total": total}
 
 @router.post("/estimate")
 async def get_price_estimate(data: schemas.EstimateRequest):
@@ -156,22 +184,51 @@ async def get_price_estimate(data: schemas.EstimateRequest):
 # --- ADMIN ORDER ENDPOINTS ---
 
 @router.get("/admin/list") # Using /admin/list to avoid conflict with /my
-async def get_admin_orders(token: str = Depends(auth_utils.oauth2_scheme)):
+async def get_admin_orders(
+    search: Optional[str] = None,
+    status: Optional[str] = None,
+    date_from: Optional[str] = None,
+    date_to: Optional[str] = None,
+    token: str = Depends(auth_utils.oauth2_scheme)
+):
     payload = auth_utils.decode_token(token)
     if not payload or payload.get("role") != 'admin':
         raise HTTPException(status_code=403, detail="Admin role required")
     
-    query = """
+    where_clauses = []
+    params = []
+
+    if search:
+        search_term = f"%{search}%"
+        where_clauses.append("(o.id LIKE %s OR o.email LIKE %s OR o.first_name LIKE %s OR o.last_name LIKE %s OR o.company_name LIKE %s OR o.phone LIKE %s OR o.shipping_address LIKE %s)")
+        params.extend([search_term] * 7)
+    
+    if status and status != 'all':
+        where_clauses.append("o.status = %s")
+        params.append(status)
+    
+    if date_from:
+        where_clauses.append("o.created_at >= %s")
+        params.append(date_from)
+    
+    if date_to:
+        where_clauses.append("o.created_at <= %s")
+        params.append(date_to)
+
+    where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
+
+    query = f"""
     SELECT o.*, u.can_chat,
            (SELECT count(*) FROM order_messages om WHERE om.order_id = o.id AND om.is_from_admin = FALSE AND om.is_read = FALSE) as unread_count,
-           GROUP_CONCAT(JSON_OBJECT('file_id', f.id, 'filename', f.filename, 'file_path', f.file_path, 'file_size', f.file_size, 'quantity', f.quantity, 'preview_path', f.preview_path, 'print_time', f.print_time, 'filament_g', f.filament_g)) as files
+           GROUP_CONCAT(IF(f.id IS NOT NULL, JSON_OBJECT('id', f.id, 'filename', f.filename, 'file_path', f.file_path, 'file_size', f.file_size, 'quantity', f.quantity, 'preview_path', f.preview_path, 'print_time', f.print_time, 'filament_g', f.filament_g), NULL)) as files
     FROM orders o
     LEFT JOIN users u ON o.user_id = u.id
     LEFT JOIN order_files f ON o.id = f.order_id
+    {where_sql}
     GROUP BY o.id
     ORDER BY o.created_at DESC
     """
-    results = db.execute_query(query)
+    results = db.execute_query(query, tuple(params))
     import session_utils
     for row in results:
         row['is_online'] = session_utils.is_user_online(row['user_id']) if row.get('user_id') else False
@@ -179,6 +236,7 @@ async def get_admin_orders(token: str = Depends(auth_utils.oauth2_scheme)):
             try: row['files'] = json.loads(f"[{row['files']}]")
             except: row['files'] = []
         else: row['files'] = []
+        row['items'] = db.execute_query("SELECT * FROM order_items WHERE order_id = %s", (row['id'],))
         photos = db.execute_query("SELECT id, file_path, is_public FROM order_photos WHERE order_id = %s", (row['id'],))
         row['photos'] = photos
     return results
@@ -208,33 +266,34 @@ async def update_order_admin(
                 data.send_notification
             )
             
-            # Generate PDF Document on moving to "shipped" step
-            if data.status == 'shipped' and not order_info[0].get('invoice_path'):
-                from services.uplatnica_generator import generate_uplatnica, generate_predracun
-                o = order_info[0]
-                price = float(o['total_price'] if o.get('total_price') is not None else o.get('estimated_price', 0))
-                
+            # Generate Payment Documents based on status transitions
+            from services.invoice_service import InvoiceService
+            o = order_info[0]
+            price_val = data.total_price if data.total_price is not None else (o.get('total_price') or o.get('estimated_price') or 0)
+            price = float(price_val)
+            
+            # 1. Proforma / Payment Slip (on 'processing' or any initial active state)
+            if data.status in ['processing', 'shipped']:
                 try:
                     if o.get('is_company'):
-                        # Fetch items for Predracun
-                        files = db.execute_query("SELECT filename as name, quantity, unit_price as price FROM order_files WHERE order_id = %s", (order_id,))
-                        pdf_path = generate_predracun(
-                            order_id, 
-                            o.get('company_name'), 
-                            o.get('company_pib'), 
-                            o.get('company_address') or o.get('shipping_address'),
-                            price,
-                            files
-                        )
+                        pdf_path = InvoiceService.generate_document(o, doc_type="predracun", override_price=price)
                     else:
                         payer_name = f"{o['first_name']} {o.get('last_name', '')}".strip()
-                        addr = o.get('shipping_address', '')
-                        pdf_path = generate_uplatnica(order_id, payer_name, addr, price)
+                        pdf_path = InvoiceService.generate_uplatnica(order_id, payer_name, o.get('shipping_address', ''), price)
                     
+                    update_fields.append("proforma_path = %s")
+                    params.append(pdf_path)
+                except Exception as e:
+                    print(f"Failed to generate proforma: {e}")
+
+            # 2. Final Invoice (only on 'shipped')
+            if data.status == 'shipped':
+                try:
+                    pdf_path = InvoiceService.generate_document(o, doc_type="faktura", override_price=price)
                     update_fields.append("invoice_path = %s")
                     params.append(pdf_path)
                 except Exception as e:
-                    print(f"Failed to generate invoice PDF: {e}")
+                    print(f"Failed to generate final invoice: {e}")
 
     if data.status:
         update_fields.append("status = %s")
@@ -265,6 +324,21 @@ async def update_order_admin(
         update_fields.append("quantity = %s")
         params.append(data.quantity)
     
+    if data.fiscal_qr_url is not None:
+        update_fields.append("fiscal_qr_url = %s")
+        params.append(data.fiscal_qr_url)
+        # Auto-set fiscalized_at if adding URL for the first time
+        if order_info[0].get('fiscalized_at') is None:
+            update_fields.append("fiscalized_at = %s")
+            params.append(datetime.now())
+
+    if data.ikof is not None:
+        update_fields.append("ikof = %s")
+        params.append(data.ikof)
+    if data.jikr is not None:
+        update_fields.append("jikr = %s")
+        params.append(data.jikr)
+    
     if update_fields:
         query = f"UPDATE orders SET {', '.join(update_fields)} WHERE id = %s"
         params.append(order_id)
@@ -358,3 +432,94 @@ async def admin_delete_file(
         request=request
     )
     return {"status": "success"}
+    
+class OrderItemSchema(BaseModel):
+    description: str
+    quantity: int
+    unit_price: float
+
+@router.get("/{order_id}/items")
+async def get_order_items(order_id: int):
+    items = db.execute_query("SELECT * FROM order_items WHERE order_id = %s", (order_id,))
+    return items
+
+@router.put("/{order_id}/items")
+async def update_order_items(order_id: int, items: List[OrderItemSchema], token: str = Depends(auth_utils.oauth2_scheme)):
+    payload = auth_utils.decode_token(token)
+    if not payload or payload.get("role") != 'admin':
+        raise HTTPException(status_code=403, detail="Admin role required")
+    
+    db.execute_commit("DELETE FROM order_items WHERE order_id = %s", (order_id,))
+    
+    total_order_price = 0
+    for item in items:
+        tot_p = round(item.quantity * item.unit_price, 2)
+        total_order_price += tot_p
+        db.execute_commit(
+            "INSERT INTO order_items (order_id, description, quantity, unit_price, total_price) VALUES (%s, %s, %s, %s, %s)",
+            (order_id, item.description, item.quantity, item.unit_price, tot_p)
+        )
+    
+    # Sync main order total_price
+    db.execute_commit("UPDATE orders SET total_price = %s WHERE id = %s", (total_order_price, order_id))
+    
+    return {"status": "success", "total_price": total_order_price}
+
+@router.delete("/{order_id}/admin")
+async def delete_order_admin(
+    order_id: int,
+    request: Request,
+    token: str = Depends(auth_utils.oauth2_scheme)
+):
+    payload = auth_utils.decode_token(token)
+    if not payload or payload.get("role") != 'admin':
+        raise HTTPException(status_code=403, detail="Admin role required")
+        
+    # 1. Find all related files to delete from disk
+    files = db.execute_query("SELECT file_path, preview_path FROM order_files WHERE order_id = %s", (order_id,))
+    photos = db.execute_query("SELECT file_path FROM order_photos WHERE order_id = %s", (order_id,))
+    
+    base_dir = config.BASE_DIR
+    
+    # Delete order_files from disk
+    for f in files:
+        try:
+            if f.get('file_path'):
+                fpath = os.path.join(base_dir, f['file_path'])
+                if os.path.exists(fpath): os.remove(fpath)
+            if f.get('preview_path'):
+                ppath = os.path.join(base_dir, f['preview_path'])
+                if os.path.exists(ppath): os.remove(ppath)
+        except Exception as e:
+            print(f"Error deleting order file {f.get('file_path')}: {e}")
+
+    # Delete photos from disk
+    for p in photos:
+        try:
+            if p.get('file_path'):
+                fpath = os.path.join(base_dir, p['file_path'])
+                if os.path.exists(fpath): os.remove(fpath)
+        except Exception as e:
+            print(f"Error deleting order photo {p.get('file_path')}: {e}")
+            
+    # 2. Delete from DB tables (due to possible lack of CASCADE or to be explicit)
+    try:
+        db.execute_commit("DELETE FROM order_messages WHERE order_id = %s", (order_id,))
+        db.execute_commit("DELETE FROM order_items WHERE order_id = %s", (order_id,))
+        db.execute_commit("DELETE FROM order_files WHERE order_id = %s", (order_id,))
+        db.execute_commit("DELETE FROM order_photos WHERE order_id = %s", (order_id,))
+        db.execute_commit("DELETE FROM orders WHERE id = %s", (order_id,))
+        
+        # LOG ACTION
+        await audit_service.log(
+            user_id=payload.get("id"),
+            action="delete_order_entirely",
+            target_type="order",
+            target_id=order_id,
+            details={"order_id": order_id},
+            request=request
+        )
+        return {"status": "success", "message": f"Order {order_id} deleted entirely"}
+    except Exception as e:
+        print(f"Failed to delete order {order_id}: {e}")
+        raise HTTPException(status_code=500, detail=str(e))

+ 15 - 0
backend/schemas.py

@@ -13,6 +13,10 @@ class MaterialBase(BaseModel):
     desc_ru: Optional[str] = None
     desc_ua: Optional[str] = None
     desc_me: Optional[str] = None
+    long_desc_en: Optional[str] = None
+    long_desc_ru: Optional[str] = None
+    long_desc_ua: Optional[str] = None
+    long_desc_me: Optional[str] = None
     price_per_cm3: float
     available_colors: Optional[List[str]] = None
     is_active: bool
@@ -40,6 +44,10 @@ class MaterialCreate(BaseModel):
     desc_ru: str
     desc_ua: str
     desc_me: str
+    long_desc_en: Optional[str] = ""
+    long_desc_ru: Optional[str] = ""
+    long_desc_ua: Optional[str] = ""
+    long_desc_me: Optional[str] = ""
     price_per_cm3: float
     available_colors: Optional[List[str]] = None
     is_active: bool = True
@@ -53,6 +61,10 @@ class MaterialUpdate(BaseModel):
     desc_ru: Optional[str] = None
     desc_ua: Optional[str] = None
     desc_me: Optional[str] = None
+    long_desc_en: Optional[str] = None
+    long_desc_ru: Optional[str] = None
+    long_desc_ua: Optional[str] = None
+    long_desc_me: Optional[str] = None
     price_per_cm3: Optional[float] = None
     available_colors: Optional[List[str]] = None
     is_active: Optional[bool] = None
@@ -157,6 +169,9 @@ class AdminOrderUpdate(BaseModel):
     color_name: Optional[str] = None
     quantity: Optional[int] = None
     send_notification: Optional[bool] = False
+    fiscal_qr_url: Optional[str] = None
+    ikof: Optional[str] = None
+    jikr: Optional[str] = None
 
 class EstimateRequest(BaseModel):
     material_id: int

+ 10 - 0
backend/scratch/check_messages.py

@@ -0,0 +1,10 @@
+import sys
+import os
+backend_dir = r"d:\radionica3d\backend"
+sys.path.append(backend_dir)
+import db
+
+res = db.execute_query("SELECT * FROM order_messages WHERE message IS NULL OR message = ''")
+print(f"Empty messages found: {len(res)}")
+for m in res:
+    print(m)

+ 33 - 0
backend/scratch/create_mock_orders.py

@@ -0,0 +1,33 @@
+import db
+import json
+import random
+from datetime import datetime, timedelta
+
+USER_ID = 2
+MATERIAL_ID = 1
+COUNT = 15
+
+statuses = ['pending', 'processing', 'shipped', 'completed', 'cancelled']
+
+print(f"Creating {COUNT} mock orders for user {USER_ID}...")
+
+for i in range(COUNT):
+    status = random.choice(statuses)
+    created_at = datetime.now() - timedelta(days=i)
+    days_ago = i
+    
+    query = """
+    INSERT INTO orders (
+        user_id, material_id, first_name, last_name, phone, email, 
+        shipping_address, status, estimated_price, total_price, 
+        material_name, material_price, color_name, quantity, created_at
+    ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+    """
+    params = (
+        USER_ID, MATERIAL_ID, "Test", f"User {i}", "123456789", "user@radionica3d.com",
+        f"Mock Street {i}, City", status, 10.0 + i, 12.0 + i,
+        "PLA", 5.0, "White", 1, created_at
+    )
+    db.execute_commit(query, params)
+
+print("Done creating mock orders.")

+ 37 - 0
backend/scratch/test_pdf.py

@@ -0,0 +1,37 @@
+import os
+import sys
+
+# Ensure we can import from the current directory
+sys.path.append(os.getcwd())
+
+import config
+from services.invoice_service import InvoiceService
+
+# Mock order data
+order_data = {
+    'id': 999,
+    'first_name': 'Test',
+    'last_name': 'User',
+    'total_price': 100.0,
+    'is_company': False,
+    'shipping_address': 'Test Address 123'
+}
+
+print("--- START TEST GENERATION ---")
+try:
+    path = InvoiceService.generate_document(order_data, doc_type="faktura")
+    print(f"SUCCESS! Service returned path: {path}")
+    
+    # Check if file exists physically
+    abs_path = os.path.abspath(os.path.join(config.UPLOAD_DIR, "invoices", "faktura_order_999.pdf"))
+    if os.path.exists(abs_path):
+        print(f"CONFIRMED: File exists at {abs_path}")
+    else:
+        print(f"FAIL: File NOT FOUND at {abs_path}")
+        # Let's see what IS in the upload dir
+        print(f"Contents of {config.UPLOAD_DIR}: {os.listdir(config.UPLOAD_DIR) if os.path.exists(config.UPLOAD_DIR) else 'DIR NOT FOUND'}")
+except Exception as e:
+    print(f"CRASH: {e}")
+    import traceback
+    traceback.print_exc()
+print("--- END TEST GENERATION ---")

+ 11 - 0
backend/scratch/verify_pagination.py

@@ -0,0 +1,11 @@
+import db
+
+user_id = 2
+page1 = db.execute_query('SELECT id FROM orders WHERE user_id = %s ORDER BY created_at DESC LIMIT 10 OFFSET 0', (user_id,))
+page2 = db.execute_query('SELECT id FROM orders WHERE user_id = %s ORDER BY created_at DESC LIMIT 10 OFFSET 10', (user_id,))
+
+print(f"Page 1 (10 orders): {[r['id'] for r in page1]}")
+print(f"Page 2 (remaining orders): {[r['id'] for r in page2]}")
+
+count_res = db.execute_query('SELECT COUNT(*) as total FROM orders WHERE user_id = %s', (user_id,))
+print(f"Total found: {count_res[0]['total']}")

+ 11 - 1
backend/services/audit_service.py

@@ -21,7 +21,17 @@ class AuditService:
         details_str = None
         if details:
             if isinstance(details, (dict, list)):
-                details_str = json.dumps(details, ensure_ascii=False)
+                # Convert details to JSON string, handling Decimals and other types
+                def decimal_default(obj):
+                    try:
+                        from decimal import Decimal
+                        if isinstance(obj, Decimal):
+                            return float(obj)
+                    except:
+                        pass
+                    return str(obj)
+
+                details_str = json.dumps(details, ensure_ascii=False, default=decimal_default)
             else:
                 details_str = str(details)
         

+ 9 - 0
backend/services/event_hooks.py

@@ -38,3 +38,12 @@ def on_order_status_changed(order_id: int, status: str, order_data: dict, send_n
         print(f"--> Preparing notification to {user_email} (User: {first_name}, Lang: {lang})...")
         import notifications
         notifications.notify_status_change(user_email, order_id, status, first_name, lang)
+
+def on_message_received(order_id: int, user_id: int, message: str):
+    """
+    Hook triggered when a client sends a message to an order chat.
+    Useful for alerting admins via external systems.
+    """
+    print(f"EVENT: New message for Order {order_id} from User {user_id}: {message[:20]}...")
+    # TODO: Integration logic (Telegram, Slack, Email to shop owner, etc.)
+    pass

+ 80 - 0
backend/services/fiscal_service.py

@@ -0,0 +1,80 @@
+import hashlib
+import hmac
+from datetime import datetime
+import config
+import logging
+
+# Note: In production we would use 'cryptography' or 'xmlsig' for real RSA signing
+# For now, we draft the contour and logic flow
+
+class FiscalService:
+    @staticmethod
+    def generate_ikof(order_id, total_amount, timestamp=None):
+        """
+        Generates Internal Fiscal Code (IKOF).
+        Formula: Signature(PIB|Date|Num|Unit|ENU|Op|Total)
+        """
+        if not timestamp:
+            timestamp = datetime.now().strftime("%Y-%m-%dT%H:%M:%S+02:00")
+            
+        # Simplified representation of the fiscal string
+        # Real EFI requires precise ordering and pipe-separation
+        fiscal_string = (
+            f"{config.COMPANY_PIB}|"
+            f"{timestamp}|"
+            f"{order_id}|"
+            f"{config.EFI_BUS_UNIT}|"
+            f"{config.EFI_ENU_CODE}|"
+            f"{config.EFI_OPERATOR}|"
+            f"{total_amount:.2f}"
+        )
+        
+        # In real life, this is RSA signed with your .p12 certificate
+        # Here we do a mock MD5/SHA1 hash just to show the flow
+        ikof = hashlib.md5(fiscal_string.encode()).hexdigest().upper()
+        return ikof, timestamp
+
+    @staticmethod
+    def generate_qr_url(jikr, ikof, timestamp, total_amount):
+        """
+        Generates the official Tax Authority verify URL for the QR code.
+        """
+        base_url = "https://efi.porezi.me/verify/#/verify?" if not config.EFI_STAGING else "https://test-efi.porezi.me/verify/#/verify?"
+        
+        # Parameters required for verification
+        params = [
+            f"iic={ikof}",
+            f"crtd={timestamp}",
+            f"ord={total_amount:.2f}",
+            f"bu={config.EFI_BUS_UNIT}",
+            f"cr={config.EFI_ENU_CODE}",
+            f"sw={config.EFI_OPERATOR}",
+            f"prc={total_amount:.2f}" # Simplified
+        ]
+        
+        if jikr:
+            params.insert(0, f"fic={jikr}")
+            
+        return base_url + "&".join(params)
+
+    @staticmethod
+    async def register_at_upc(invoice_data):
+        """
+        MOCK: Send SOAP request to Tax Authority (Uprava Prihoda i Carina)
+        Returns JIKR (fic) on success.
+        """
+        if not config.EFI_ENABLED:
+            logging.info("Fiscalization is disabled in config. Skipping real registration.")
+            return "MOCK-JIKR-12345-67890"
+            
+        # 1. Prepare XML with digital signature (WSS)
+        # 2. POST to https://cis.porezi.me/FiscalizationService
+        # 3. Parse JIKR from response
+        
+        try:
+            # Here logic for SOAP request with zeep or requests
+            # For now, we simulate success
+            return "REAL-JIKR-FROM-TAX-SERVER"
+        except Exception as e:
+            logging.error(f"Fiscalization failed: {e}")
+            return None

+ 25 - 6
backend/services/global_manager.py

@@ -40,19 +40,38 @@ class GlobalConnectionManager:
         count = row[0]['cnt'] if row else 0
         await self.send_unread_count(user_id, count)
 
-    async def notify_admins(self):
-        import db
-        row = db.execute_query("SELECT count(*) as cnt FROM order_messages WHERE is_from_admin = FALSE AND is_read = FALSE")
-        count = row[0]['cnt'] if row else 0
-        payload = json.dumps({"type": "unread_count", "count": count})
+    async def broadcast_to_role(self, role_name: str, payload: str):
         for uid, role in self.user_roles.items():
-            if role == 'admin' and uid in self.active_connections:
+            if role == role_name and uid in self.active_connections:
                 for ws in self.active_connections[uid]:
                     try:
                         await ws.send_text(payload)
                     except:
                         pass
 
+    async def notify_admins(self):
+        import db
+        row = db.execute_query("SELECT count(*) as cnt FROM order_messages WHERE is_from_admin = FALSE AND is_read = FALSE")
+        count = row[0]['cnt'] if row else 0
+        payload = json.dumps({"type": "unread_count", "count": count})
+        await self.broadcast_to_role('admin', payload)
+
+    async def notify_admins_new_message(self, order_id: int, message_text: str):
+        hint = (message_text[:50] + '...') if len(message_text) > 50 else message_text
+        payload = json.dumps({
+            "type": "new_chat_message",
+            "order_id": order_id,
+            "text": hint
+        })
+        await self.broadcast_to_role('admin', payload)
+
+    async def notify_order_read(self, order_id: int):
+        payload = json.dumps({
+            "type": "order_read",
+            "order_id": order_id
+        })
+        await self.broadcast_to_role('admin', payload)
+
     async def kick_user(self, user_id: int):
         if user_id in self.active_connections:
             payload = json.dumps({"type": "account_suspended"})

+ 276 - 0
backend/services/invoice_service.py

@@ -0,0 +1,276 @@
+import os
+from fpdf import FPDF
+import config
+from datetime import datetime
+import qrcode
+import io
+from services.fiscal_service import FiscalService
+
+class PDFGenerator(FPDF):
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        self.set_auto_page_break(True, margin=15)
+        
+    def header_logo(self):
+        # Placeholder for a logo if needed
+        self.set_font("helvetica", "B", 20)
+        self.set_text_color(31, 41, 55) # text-gray-800
+        self.cell(0, 10, config.COMPANY_NAME, ln=True, align='L')
+        self.set_font("helvetica", "", 8)
+        self.set_text_color(107, 114, 128) # text-gray-500
+        self.cell(0, 4, config.COMPANY_ADDRESS, ln=True)
+        self.cell(0, 4, f"PIB: {config.COMPANY_PIB} | Ziro racun: {config.ZIRO_RACUN}", ln=True)
+        self.ln(10)
+
+    def draw_table_header(self, columns):
+        self.set_fill_color(249, 250, 251) # gray-50
+        self.set_text_color(55, 65, 81) # gray-700
+        self.set_font("helvetica", "B", 9)
+        
+        for col in columns:
+            self.cell(col['w'], 8, col['label'], border=1, fill=True, align=col['align'])
+        self.ln()
+
+    def format_money(self, amount):
+        return f"{amount:.2f}".replace(".", ",")
+
+class InvoiceService:
+    @staticmethod
+    def _get_output_path(filename):
+        pdf_dir = os.path.join(config.UPLOAD_DIR, "invoices")
+        os.makedirs(pdf_dir, exist_ok=True)
+        filepath = os.path.join(pdf_dir, filename)
+        return filepath, os.path.join("uploads", "invoices", filename).replace("\\", "/")
+
+    @staticmethod
+    def generate_uplatnica(order_id, payer_name, payer_address, amount):
+        """Generates a standard Payment Slip (Uplatnica) for individuals"""
+        pdf = FPDF(orientation='P', unit='mm', format='A4')
+        pdf.set_auto_page_break(False)
+        pdf.add_page()
+        pdf.set_font("helvetica", size=7)
+
+        # Draw the structure (as in previous version but can be polished)
+        pdf.rect(0, 0, 210, 99)
+        pdf.line(105, 0, 105, 99)
+
+        # ... (transferring the logic from uplatnica_generator.py) ...
+        # I will keep the existing precise layout for uplatnica since it's a standard form
+        labels = ["Hitnost", "Prenos", "Uplata", "Isplata"]
+        for i, label in enumerate(labels):
+            x = 110 + i * 22
+            pdf.set_xy(x, 2)
+            pdf.cell(14, 4, label)
+            pdf.rect(x + 14, 2, 4, 4)
+            if label == "Uplata":
+                pdf.line(x + 14, 2, x + 18, 6); pdf.line(x + 18, 2, x + 14, 6)
+
+        pdf.set_font("helvetica", "B", 8)
+        pdf.set_xy(5, 5); pdf.cell(95, 5, "NALOG PLATIOCA", align="C")
+        
+        pdf.rect(5, 12, 95, 14)
+        pdf.set_font("helvetica", size=9)
+        pdf.set_xy(7, 14); pdf.cell(90, 4, payer_name[:40])
+        pdf.set_xy(7, 18); pdf.cell(90, 4, payer_address[:40])
+
+        pdf.set_font("helvetica", size=6)
+        pdf.set_xy(5, 26); pdf.cell(95, 4, "(Naziv platioca)", align="C")
+
+        pdf.rect(5, 30, 95, 14)
+        pdf.set_font("helvetica", size=9)
+        pdf.set_xy(7, 32); pdf.cell(90, 4, "Usluge 3D stampe")
+        pdf.set_xy(7, 36); pdf.cell(90, 4, f"Narudzba {order_id}")
+
+        pdf.set_font("helvetica", size=6)
+        pdf.set_xy(5, 44); pdf.cell(95, 4, "(Svrha placanja)", align="C")
+
+        pdf.rect(5, 48, 95, 14)
+        pdf.set_font("helvetica", size=9)
+        pdf.set_xy(7, 50); pdf.cell(90, 4, config.COMPANY_NAME)
+
+        pdf.set_font("helvetica", size=6)
+        pdf.set_xy(5, 62); pdf.cell(95, 4, "(Naziv primaoca)", align="C")
+
+        pdf.set_font("helvetica", size=7)
+        pdf.set_xy(110, 22); pdf.cell(10, 4, "EUR")
+        pdf.rect(120, 22, 50, 10); pdf.rect(175, 22, 30, 10)
+        
+        pdf.set_font("helvetica", "B", 11)
+        pdf.set_xy(120, 24); pdf.cell(50, 6, f"{amount:.2f}".replace(".", ","), align="C")
+        pdf.set_xy(175, 24); pdf.cell(30, 6, "121", align="C")
+
+        pdf.rect(110, 36, 95, 10)
+        pdf.set_font("helvetica", size=10)
+        pdf.set_xy(110, 38); pdf.cell(95, 6, config.ZIRO_RACUN, align="C")
+
+        pdf.rect(110, 50, 20, 8); pdf.rect(135, 50, 70, 8)
+        pdf.set_xy(110, 52); pdf.cell(20, 4, "00", align="C")
+        pdf.set_xy(135, 52); pdf.cell(70, 4, str(order_id), align="C")
+
+        filename = f"uplatnica_order_{order_id}.pdf"
+        filepath, web_path = InvoiceService._get_output_path(filename)
+        pdf.output(filepath)
+        return web_path
+
+    @staticmethod
+    def generate_document(order_data, doc_type="predracun", override_price=None):
+        """
+        Generic generator for Predracun (Proforma) or Faktura (Invoice)
+        Compliant with Montenegro VAT laws.
+        """
+        pdf = PDFGenerator()
+        pdf.add_page()
+        pdf.header_logo()
+
+        title = "PREDRACUN (Proforma Invoice / Avansni racun)" if doc_type == "predracun" else "FAKTURA / RACUN (Invoice)"
+        doc_id = f"{order_data['id']}/{datetime.now().year}"
+        now_str = datetime.now().strftime('%d.%m.%Y.')
+        
+        pdf.set_font("helvetica", "B", 14)
+        pdf.cell(0, 10, f"{title} #{doc_id}", ln=True)
+        pdf.set_font("helvetica", "", 9)
+        pdf.cell(0, 5, f"Mjesto izdavanja: {config.COMPANY_CITY}", ln=True)
+        pdf.cell(0, 5, f"Datum izdavanja: {now_str}", ln=True)
+        pdf.cell(0, 5, f"Datum prometa: {now_str}", ln=True)
+        pdf.ln(10)
+
+        # Buyer and Seller horizontal layout
+        pdf.set_font("helvetica", "B", 10)
+        pdf.cell(95, 5, "PRODAVAC / SUPPLIER:", ln=False)
+        pdf.cell(95, 5, "KUPAC / CUSTOMER:", ln=True)
+        
+        pdf.set_font("helvetica", "", 9)
+        # Supplier Details
+        current_y = pdf.get_y()
+        pdf.multi_cell(90, 4, f"{config.COMPANY_NAME}\n{config.COMPANY_ADDRESS}\nPIB: {config.COMPANY_PIB}\nZiro racun: {config.ZIRO_RACUN}")
+        
+        # Customer Details
+        pdf.set_xy(105, current_y)
+        is_company = bool(order_data.get('is_company'))
+        buyer_name = order_data.get('company_name') or f"{order_data.get('first_name', '')} {order_data.get('last_name', '')}"
+        buyer_address = order_data.get('company_address') or order_data.get('shipping_address', 'N/A')
+        buyer_pib = order_data.get('company_pib') or ""
+        # Note: Buyer's bank account isn't collected, so we leave it as N/A or empty
+        pdf.multi_cell(90, 4, f"{buyer_name}\nAddress: {buyer_address}\nPIB/JMBG: {buyer_pib}\nZiro racun: N/A")
+        
+        pdf.ln(10)
+        pdf.set_xy(10, pdf.get_y() + 5)
+
+        # Items Table
+        columns = [
+            {'w': 10, 'label': '#', 'align': 'C'},
+            {'w': 90, 'label': 'Opis / Description', 'align': 'L'},
+            {'w': 20, 'label': 'Kol / Qty', 'align': 'C'},
+            {'w': 35, 'label': 'Iznos / Total', 'align': 'R'},
+        ]
+        pdf.draw_table_header(columns)
+
+        pdf.set_font("helvetica", "", 9)
+        
+        # Fetch actual fixed items from DB
+        items = db.execute_query("SELECT * FROM order_items WHERE order_id = %s", (order_data['id'],))
+        
+        if not items:
+            # Fallback to legacy single row if no items found (for old orders)
+            total_amount = float(override_price if override_price is not None else (order_data.get('total_price') or 0))
+            pdf.cell(10, 10, "1", border=1, align='C')
+            pdf.cell(90, 10, f"Usluge 3D stampe (Order #{order_data['id']})", border=1)
+            pdf.cell(20, 10, "1", border=1, align='C')
+            pdf.cell(35, 10, pdf.format_money(total_amount), border=1, align='R', ln=True)
+        else:
+            total_amount = 0
+            for idx, item in enumerate(items, 1):
+                item_total = float(item['total_price'])
+                total_amount += item_total
+                pdf.cell(10, 8, str(idx), border=1, align='C')
+                pdf.cell(90, 8, str(item['description']), border=1)
+                pdf.cell(20, 8, str(item['quantity']), border=1, align='C')
+                pdf.cell(35, 8, pdf.format_money(item_total), border=1, align='R', ln=True)
+
+        # Summary
+        pdv_rate = config.PDV_RATE
+        pdf.ln(5)
+        pdf.set_font("helvetica", "", 10)
+        base_amount = total_amount / (1 + pdv_rate/100)
+        pdv_amount = total_amount - base_amount
+
+        pdf.cell(155, 6, "Osnovica / Base Amount (bez PDV):", align='R')
+        pdf.cell(35, 6, pdf.format_money(base_amount), align='R', ln=True)
+        pdf.cell(155, 6, f"PDV {pdv_rate}% / VAT Amount:", align='R')
+        pdf.cell(35, 6, pdf.format_money(pdv_amount), align='R', ln=True)
+
+        pdf.set_font("helvetica", "B", 12)
+        pdf.cell(155, 12, "UKUPNO / TOTAL (EUR):", align='R')
+        pdf.cell(35, 12, pdf.format_money(total_amount), align='R', ln=True)
+
+        pdf.ln(20)
+        
+        # --- FISCALIZATION BLOCK ---
+        print(f"DEBUG: Generating document type: {doc_type}")
+        manual_qr = order_data.get('fiscal_qr_url')
+        manual_ikof = order_data.get('ikof')
+        manual_jikr = order_data.get('jikr')
+        manual_ts = order_data.get('fiscalized_at')
+
+        if doc_type == "faktura" or manual_qr:
+            print("DEBUG: Entering Fiscalization Block")
+            
+            # Use manual data if available, otherwise generate mock/active
+            ikof = manual_ikof or FiscalService.generate_ikof(order_data['id'], total_amount)[0]
+            ts_obj = manual_ts or datetime.now()
+            ts = ts_obj.strftime("%Y-%m-%dT%H:%M:%S+02:00") if hasattr(ts_obj, 'strftime') else str(ts_obj)
+            jikr = manual_jikr or "MOCK-JIKR-EFI-STAGING-123"
+            
+            qr_url = manual_qr or FiscalService.generate_qr_url(jikr, ikof, ts, total_amount)
+            print(f"DEBUG: QR URL: {qr_url}")
+            
+            # 4. Create QR Image
+            qr = qrcode.QRCode(box_size=10, border=0)
+            qr.add_data(qr_url)
+            qr.make(fit=True)
+            img_qr = qr.make_image(fill_color="black", back_color="white")
+            
+            # Convert to bytes for FPDF
+            img_byte_arr = io.BytesIO()
+            img_qr.save(img_byte_arr, format='PNG')
+            img_byte_arr.seek(0)
+            
+            # 5. Place QR and Fiscal Info
+            try:
+                pdf.image(img_byte_arr, x=10, y=pdf.get_y(), w=30, h=30, type='PNG')
+            except Exception as e:
+                print(f"DEBUG: Failed to embed QR image: {e}")
+            
+            pdf.set_xy(45, pdf.get_y() + 5)
+            pdf.set_font("helvetica", "B", 8)
+            pdf.cell(0, 4, f"IKOF: {ikof}", ln=True)
+            pdf.set_xy(45, pdf.get_y())
+            pdf.cell(0, 4, f"JIKR: {jikr}", ln=True)
+            pdf.set_xy(45, pdf.get_y())
+            pdf.set_font("helvetica", "", 7)
+            pdf.cell(0, 4, f"Fiskalizovano: {ts}", ln=True)
+            
+            pdf.set_y(pdf.get_y() + 15)
+        # ---------------------------
+
+        pdf.set_font("helvetica", "", 9)
+        pdf.cell(95, 10, "Fakturisao (Izdavalac):", ln=False)
+        pdf.cell(95, 10, "Primio (Kupac):", ln=True)
+        pdf.ln(5)
+        pdf.line(10, pdf.get_y(), 80, pdf.get_y())
+        pdf.line(110, pdf.get_y(), 180, pdf.get_y())
+
+        # Footer
+        pdf.ln(15)
+        pdf.set_font("helvetica", "I", 8)
+        if doc_type == "predracun":
+            msg = "Predracun je validan bez pecata i potpisa shodno clanu 31 Zakona o PDV. Placanje se vrsi u roku od 3 dana na navedeni ziro racun.\nThis is a proforma invoice and is valid without stamp and signature."
+        else:
+            msg = "Faktura je validna bez pecata i potpisa shodno clanu 31 Zakona o PDV. Roba/usluga je isporucena u skladu sa ugovorom.\nThis is a final invoice and is valid without stamp and signature."
+        pdf.multi_cell(0, 5, msg)
+
+        filename = f"{doc_type}_order_{order_data['id']}.pdf"
+        filepath, web_path = InvoiceService._get_output_path(filename)
+        print(f"DEBUG: Saving PDF to absolute path: {os.path.abspath(filepath)}")
+        pdf.output(filepath)
+        return web_path

+ 0 - 240
backend/services/uplatnica_generator.py

@@ -1,240 +0,0 @@
-import os
-from fpdf import FPDF
-import config
-
-from fpdf import FPDF
-import os
-import config
-
-
-def format_amount(amount):
-    return f"{amount:.2f}".replace(".", ",")
-
-
-def generate_uplatnica(order_id, payer_name, payer_address, amount):
-    pdf = FPDF(orientation='P', unit='mm', format='A4')
-    pdf.set_auto_page_break(False)
-    pdf.add_page()
-
-    pdf.set_font("helvetica", size=7)
-
-    # === UPLATNICA AREA (top of A4) ===
-    pdf.rect(0, 0, 210, 99)
-
-    # Vertical divider
-    pdf.line(105, 0, 105, 99)
-
-    # ===== TOP OPTIONS =====
-    labels = ["Hitnost", "Prenos", "Uplata", "Isplata"]
-    start_x = 110
-    step = 22
-
-    for i, label in enumerate(labels):
-        x = start_x + i * step
-        pdf.set_xy(x, 2)
-        pdf.cell(14, 4, label)
-
-        pdf.rect(x + 14, 2, 4, 4)
-
-        if label == "Uplata":
-            pdf.line(x + 14, 2, x + 18, 6)
-            pdf.line(x + 18, 2, x + 14, 6)
-
-    # ===== LEFT =====
-    pdf.set_font("helvetica", "B", 8)
-    pdf.set_xy(5, 5)
-    pdf.cell(95, 5, "NALOG PLATIOCA", align="C")
-
-    pdf.rect(5, 12, 95, 14)
-    pdf.set_font("helvetica", size=9)
-    pdf.set_xy(7, 14)
-    pdf.cell(90, 4, payer_name[:40])
-    pdf.set_xy(7, 18)
-    pdf.cell(90, 4, payer_address[:40])
-
-    pdf.set_font("helvetica", size=6)
-    pdf.set_xy(5, 26)
-    pdf.cell(95, 4, "(Naziv platioca)", align="C")
-
-    pdf.rect(5, 30, 95, 14)
-    pdf.set_font("helvetica", size=9)
-    pdf.set_xy(7, 32)
-    pdf.cell(90, 4, "Usluge 3D stampe")
-    pdf.set_xy(7, 36)
-    pdf.cell(90, 4, f"Narudzba {order_id}")
-
-    pdf.set_font("helvetica", size=6)
-    pdf.set_xy(5, 44)
-    pdf.cell(95, 4, "(Svrha placanja)", align="C")
-
-    pdf.rect(5, 48, 95, 14)
-    pdf.set_font("helvetica", size=9)
-    pdf.set_xy(7, 50)
-    pdf.cell(90, 4, config.COMPANY_NAME[:40])
-
-    pdf.set_font("helvetica", size=6)
-    pdf.set_xy(5, 62)
-    pdf.cell(95, 4, "(Naziv primaoca)", align="C")
-
-    pdf.line(5, 90, 100, 90)
-    pdf.set_xy(5, 90)
-    pdf.cell(95, 4, "(Potpis platioca)", align="C")
-
-    # ===== RIGHT =====
-    pdf.rect(110, 12, 95, 8)
-
-    pdf.set_font("helvetica", size=7)
-    pdf.set_xy(110, 22)
-    pdf.cell(10, 4, "EUR")
-
-    pdf.rect(120, 22, 50, 10)
-    pdf.rect(175, 22, 30, 10)
-
-    pdf.set_font("helvetica", "B", 11)
-    pdf.set_xy(120, 24)
-    pdf.cell(50, 6, format_amount(amount), align="C")
-
-    pdf.set_xy(175, 24)
-    pdf.cell(30, 6, "121", align="C")
-
-    pdf.set_font("helvetica", size=6)
-    pdf.set_xy(120, 32)
-    pdf.cell(50, 4, "(Iznos)", align="C")
-
-    pdf.set_xy(175, 32)
-    pdf.cell(30, 4, "(Sifra)", align="C")
-
-    pdf.rect(110, 36, 95, 10)
-    pdf.set_font("helvetica", size=10)
-    pdf.set_xy(110, 38)
-    pdf.cell(95, 6, config.ZIRO_RACUN, align="C")
-
-    pdf.rect(110, 50, 20, 8)
-    pdf.rect(135, 50, 70, 8)
-
-    pdf.set_xy(110, 52)
-    pdf.cell(20, 4, "00", align="C")
-    pdf.set_xy(135, 52)
-    pdf.cell(70, 4, str(order_id), align="C")
-
-    pdf.line(110, 90, 205, 90)
-    pdf.set_xy(110, 90)
-    pdf.cell(95, 4, "(Potpis primaoca)", align="C")
-
-    # ===== SAVE =====
-
-
-    pdf_dir = os.path.join(config.UPLOAD_DIR, "invoices")
-    os.makedirs(pdf_dir, exist_ok=True)
-
-    filename = f"uplatnica_order_{order_id}.pdf"
-    filepath = os.path.join(pdf_dir, filename)
-    pdf.output(filepath)
-
-    return os.path.join("uploads", "invoices", filename).replace("\\", "/")
-
-
-def generate_predracun(order_id, company_name, company_pib, company_address, amount, items=None):
-    """Generates a formal Proforma Invoice (Predračun) for companies"""
-    pdf = FPDF(orientation='P', unit='mm', format='A4')
-    pdf.add_page()
-    
-    # Fonts
-    pdf.set_font("helvetica", "B", 16)
-    pdf.cell(0, 10, "PREDRACUN (Proforma Invoice)", ln=True, align='C')
-    pdf.set_font("helvetica", "", 10)
-    pdf.cell(0, 10, f"Broj narudzbe / Order ID: {order_id}", ln=True, align='C')
-    pdf.ln(5)
-
-    # Supplier info (Left)
-    pdf.set_font("helvetica", "B", 10)
-    pdf.cell(95, 5, "PRODAVAC / SUPPLIER:", ln=False)
-    # Customer info (Right)
-    pdf.cell(95, 5, "KUPAC / CUSTOMER:", ln=True)
-    
-    pdf.set_font("helvetica", "", 10)
-    current_y = pdf.get_y()
-    
-    # Left column (Supplier)
-    pdf.set_xy(10, current_y)
-    pdf.multi_cell(90, 5, f"{config.COMPANY_NAME}\n{config.COMPANY_ADDRESS}\nPIB: {config.COMPANY_PIB}\nZiro racun: {config.ZIRO_RACUN}")
-    
-    # Right column (Customer)
-    pdf.set_xy(105, current_y)
-    pdf.multi_cell(90, 5, f"{company_name}\nAddress: {company_address}\nPIB: {company_pib}")
-    
-    pdf.ln(10)
-    pdf.set_xy(10, pdf.get_y() + 5)
-
-    # Table Header
-    pdf.set_font("helvetica", "B", 10)
-    pdf.set_fill_color(240, 240, 240)
-    pdf.cell(10, 8, "#", border=1, fill=True)
-    pdf.cell(100, 8, "Opis / Description", border=1, fill=True)
-    pdf.cell(20, 8, "Kol / Qty", border=1, fill=True, align='C')
-    pdf.cell(30, 8, "Cjena / Price", border=1, fill=True, align='R')
-    pdf.cell(30, 8, "Iznos / Total", border=1, fill=True, align='R', ln=True)
-
-    # Table Content
-    pdf.set_font("helvetica", "", 9)
-    pdv_rate = getattr(config, 'PDV_RATE', 21)
-    
-    total_base = 0.0
-    total_pdv = 0.0
-
-    if not items:
-        # Fallback
-        price_total = float(amount)
-        price_base = price_total / (1 + pdv_rate/100)
-        pdv_amount = price_total - price_base
-        total_base = price_base
-        total_pdv = pdv_amount
-
-        pdf.cell(10, 8, "1", border=1)
-        pdf.cell(100, 8, f"Usluge 3D stampe (Narudzba {order_id})", border=1)
-        pdf.cell(20, 8, "1", border=1, align='C')
-        pdf.cell(30, 8, format_amount(price_base), border=1, align='R')
-        pdf.cell(30, 8, format_amount(price_total), border=1, align='R', ln=True)
-    else:
-        for i, item in enumerate(items):
-            pdf.cell(10, 8, str(i + 1), border=1)
-            pdf.cell(100, 8, str(item.get('name', '3D Print'))[:50], border=1)
-            pdf.cell(20, 8, str(item.get('quantity', 1)), border=1, align='C')
-            
-            p_total = float(item.get('price', 0)) * item.get('quantity', 1)
-            p_base = p_total / (1 + pdv_rate/100)
-            p_pdv = p_total - p_base
-            
-            total_base += p_base
-            total_pdv += p_pdv
-            
-            pdf.cell(30, 8, format_amount(p_base / item.get('quantity', 1)), border=1, align='R')
-            pdf.cell(30, 8, format_amount(p_total), border=1, align='R', ln=True)
-
-    # Breakdown
-    pdf.ln(5)
-    pdf.set_font("helvetica", "", 10)
-    pdf.cell(160, 6, "Osnovica / Base Amount:", align='R')
-    pdf.cell(30, 6, format_amount(total_base), align='R', ln=True)
-    
-    pdf.cell(160, 6, f"PDV {pdv_rate}% / VAT Amount:", align='R')
-    pdf.cell(30, 6, format_amount(total_pdv), align='R', ln=True)
-
-    # Total
-    pdf.set_font("helvetica", "B", 12)
-    pdf.cell(160, 10, "UKUPNO / TOTAL (EUR):", align='R')
-    pdf.cell(30, 10, format_amount(float(amount)), align='R', ln=True)
-
-    # Footer
-    pdf.ln(20)
-    pdf.set_font("helvetica", "I", 8)
-    pdf.multi_cell(0, 5, "Predracun je validan bez pecata i potpisa. Placanje se vrsi u roku od 3 dana na navedeni ziro racun.\nThis is a proforma invoice and is valid without stamp and signature.")
-
-    # Save
-    pdf_dir = os.path.join(config.UPLOAD_DIR, "invoices")
-    os.makedirs(pdf_dir, exist_ok=True)
-    filename = f"predracun_order_{order_id}.pdf"
-    filepath = os.path.join(pdf_dir, filename)
-    pdf.output(filepath)
-
-    return os.path.join("uploads", "invoices", filename).replace("\\", "/")

+ 40 - 38
scripts/manage_locales.py

@@ -4,7 +4,8 @@ import sys
 from pathlib import Path
 
 LOCALES_DIR = Path("src/locales")
-MASTER_FILE = LOCALES_DIR / "translations.json"
+USER_MASTER = LOCALES_DIR / "translations.user.json"
+ADMIN_MASTER = LOCALES_DIR / "translations.admin.json"
 LANGUAGES = ["en", "me", "ru", "ua"]
 
 def get_nested_keys(data, prefix=""):
@@ -27,14 +28,14 @@ def set_nested_key(data, key_path, value):
         data = data.setdefault(part, {})
     data[parts[-1]] = value
 
-def merge():
-    """Merge individual JSON files into translations.json"""
+def _merge_files(master_file, suffix):
+    """Generic merge from {lang}{suffix} into master_file"""
     master_data = {}
     all_keys = set()
     locale_data = {}
 
     for lang in LANGUAGES:
-        path = LOCALES_DIR / f"{lang}.json"
+        path = LOCALES_DIR / f"{lang}{suffix}"
         if path.exists():
             with open(path, "r", encoding="utf-8") as f:
                 data = json.load(f)
@@ -54,67 +55,68 @@ def merge():
         translations = {}
         for lang in LANGUAGES:
             translations[lang] = locale_data.get(lang, {}).get(key, "")
-        
         set_nested_key(master_data, key, translations)
 
-    with open(MASTER_FILE, "w", encoding="utf-8") as f:
+    with open(master_file, "w", encoding="utf-8") as f:
         json.dump(master_data, f, ensure_ascii=False, indent=2)
-    
-    print(f"Merged all locales into {MASTER_FILE}")
+    print(f"Merged {suffix} files into {master_file}")
 
-def split():
-    """Split translations.json into individual JSON files"""
-    if not MASTER_FILE.exists():
-        print(f"Error: {MASTER_FILE} not found")
+def _split_master(master_file, suffix):
+    """Generic split from master_file into {lang}{suffix}"""
+    if not master_file.exists():
+        print(f"Warning: {master_file} not found, skipping split for it.")
         return
 
-    with open(MASTER_FILE, "r", encoding="utf-8") as f:
+    with open(master_file, "r", encoding="utf-8") as f:
         master_data = json.load(f)
 
     for lang in LANGUAGES:
         lang_data = {}
-        
         def unflatten(d, target, current_lang):
             for k, v in d.items():
                 if isinstance(v, dict):
-                    # Check if this is a leaf (contains language keys)
                     if any(l in v for l in LANGUAGES):
                         target[k] = v.get(current_lang, "")
                     else:
                         target[k] = {}
                         unflatten(v, target[k], current_lang)
                 else:
-                    # Should not ideally happen with the current structure
                     target[k] = v
-
-        unflatten(master_data, lang_data, lang)
         
-        output_path = LOCALES_DIR / f"{lang}.json"
+        unflatten(master_data, lang_data, lang)
+        output_path = LOCALES_DIR / f"{lang}{suffix}"
         with open(output_path, "w", encoding="utf-8") as f:
             json.dump(lang_data, f, ensure_ascii=False, indent=2)
         print(f"Generated {output_path}")
 
-def list_missing():
-    """List all keys with missing translations in translations.json"""
-    if not MASTER_FILE.exists():
-        print(f"Error: {MASTER_FILE} not found")
-        return
-
-    with open(MASTER_FILE, "r", encoding="utf-8") as f:
-        master_data = json.load(f)
+def merge():
+    """Merge individual JSON files into respective master files"""
+    _merge_files(USER_MASTER, ".json")
+    _merge_files(ADMIN_MASTER, ".admin.json")
 
-    flat_keys = get_nested_keys(master_data)
-    missing_found = False
+def split():
+    """Split master files into individual JSON files"""
+    _split_master(USER_MASTER, ".json")
+    _split_master(ADMIN_MASTER, ".admin.json")
 
-    for key, translations in sorted(flat_keys.items()):
-        if isinstance(translations, dict):
-            missing_langs = [lang for lang in LANGUAGES if not translations.get(lang)]
-            if missing_langs:
-                print(f"Key: {key} - Missing: {', '.join(missing_langs)}")
-                missing_found = True
-    
-    if not missing_found:
-        print("All translations are complete!")
+def list_missing():
+    """List all keys with missing translations in both master files"""
+    for name, path in [("User", USER_MASTER), ("Admin", ADMIN_MASTER)]:
+        if not path.exists():
+            continue
+        print(f"\n--- Missing in {name} ---")
+        with open(path, "r", encoding="utf-8") as f:
+            data = json.load(f)
+        flat_keys = get_nested_keys(data)
+        missing_found = False
+        for key, trans in sorted(flat_keys.items()):
+            if isinstance(trans, dict):
+                missing = [l for l in LANGUAGES if not trans.get(l)]
+                if missing:
+                    print(f"Key: {key} - Missing: {', '.join(missing)}")
+                    missing_found = True
+        if not missing_found:
+            print("Everything translated!")
 
 if __name__ == "__main__":
     if len(sys.argv) < 2:

+ 7 - 6
src/components/Footer.vue

@@ -43,8 +43,8 @@
       <div class="pt-6 border-t border-black/[0.04] flex flex-col md:flex-row justify-between items-center gap-4">
         <p class="text-[10px] font-bold text-foreground/30 uppercase tracking-widest">© 2024 Radionica 3D. {{ t("footer.allRightsReserved") }}</p>
         <div class="flex gap-4">
-          <router-link to="/privacy" class="text-[10px] font-bold text-foreground/30 hover:text-primary transition-colors uppercase tracking-widest">{{ t("footer.privacy") }}</router-link>
-          <router-link to="/terms" class="text-[10px] font-bold text-foreground/30 hover:text-primary transition-colors uppercase tracking-widest">{{ t("footer.terms") }}</router-link>
+          <router-link :to="`/${currentLanguage()}/privacy`" class="text-[10px] font-bold text-foreground/30 hover:text-primary transition-colors uppercase tracking-widest">{{ t("footer.privacy") }}</router-link>
+          <router-link :to="`/${currentLanguage()}/terms`" class="text-[10px] font-bold text-foreground/30 hover:text-primary transition-colors uppercase tracking-widest">{{ t("footer.terms") }}</router-link>
         </div>
       </div>
     </div>
@@ -56,6 +56,7 @@ import { computed } from "vue";
 import { useI18n } from "vue-i18n";
 import { Mail, Phone, MapPin } from "lucide-vue-next";
 import Logo from "./Logo.vue";
+import { currentLanguage } from "@/i18n";
 
 const { t } = useI18n();
 
@@ -71,10 +72,10 @@ const footerLinks = computed(() => ({
     { label: t("services.sla.title"), href: "#" },
   ] as FooterLink[],
   company: [
-    { label: t("footer.about"), to: "/about" },
-    { label: t("footer.privacy"), to: "/privacy" },
-    { label: t("footer.blog"), to: "/blog" },
-    { label: t("footer.contact"), to: "/contact" },
+    { label: t("footer.about"),   to: `/${currentLanguage()}/about` },
+    { label: t("footer.privacy"), to: `/${currentLanguage()}/privacy` },
+    { label: t("footer.blog"),    to: `/${currentLanguage()}/blog` },
+    { label: t("footer.contact"), to: `/${currentLanguage()}/contact` },
   ] as FooterLink[],
 }));
 </script>

+ 18 - 16
src/components/Header.vue

@@ -3,7 +3,7 @@
     <div class="container mx-auto px-4">
       <div class="flex items-center justify-between h-14 lg:h-16">
         <!-- Logo -->
-        <RouterLink to="/"><Logo /></RouterLink>
+        <RouterLink :to="`/${activeLang}/`"><Logo /></RouterLink>
 
         <!-- Desktop Nav -->
         <nav class="hidden lg:flex items-center gap-8">
@@ -23,7 +23,7 @@
           <div class="hidden lg:flex items-center gap-2">
           <RouterLink
             v-if="isAdmin"
-            to="/admin"
+            :to="`/${activeLang}/admin`"
             class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary hover:bg-primary/10 rounded-lg transition-colors"
           >
             <LayoutPanelTop class="w-4 h-4" />
@@ -33,7 +33,7 @@
           <!-- Unread Messages Badge -->
           <RouterLink
             v-if="isLoggedIn && authStore.unreadMessagesCount > 0"
-            :to="isAdmin ? '/admin' : '/orders'"
+            :to="isAdmin ? `/${activeLang}/admin` : `/${activeLang}/orders`"
             class="inline-flex items-center gap-1.5 px-3 py-1 bg-red-500 hover:bg-red-600 text-white text-xs font-bold rounded-full transition-all shadow-md animate-in fade-in"
             :title="t('nav.unreadTooltip')"
           >
@@ -43,7 +43,7 @@
 
           <RouterLink
             v-if="isLoggedIn"
-            to="/orders"
+            :to="`/${activeLang}/orders`"
             class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium hover:bg-secondary rounded-lg transition-colors"
           >
             <PackageCheck class="w-4 h-4" />
@@ -57,10 +57,10 @@
             </Button>
           </template>
           <template v-else>
-            <Button variant="ghost" size="sm" :as="RouterLink" to="/auth" class="text-[13px] font-bold">
+            <Button variant="ghost" size="sm" :as="RouterLink" :to="`/${activeLang}/auth`" class="text-[13px] font-bold">
               {{ t("nav.logIn") }}
             </Button>
-            <Button variant="default" size="sm" :as="RouterLink" to="/auth" class="text-[13px] font-bold shadow-md">
+            <Button variant="default" size="sm" :as="RouterLink" :to="`/${activeLang}/auth`" class="text-[13px] font-bold shadow-md">
               {{ t("nav.register") }}
             </Button>
           </template>
@@ -102,7 +102,7 @@
 
           <RouterLink
             v-if="isAdmin"
-            to="/admin"
+            :to="`/${activeLang}/admin`"
             class="flex items-center gap-2 text-primary py-2"
             @click="mobileOpen = false"
           >
@@ -111,7 +111,7 @@
 
           <RouterLink
             v-if="isLoggedIn && authStore.unreadMessagesCount > 0"
-            to="/orders"
+            :to="`/${activeLang}/orders`"
             class="flex items-center gap-2 text-red-500 font-bold py-2"
             @click="mobileOpen = false"
           >
@@ -121,7 +121,7 @@
 
           <RouterLink
             v-if="isLoggedIn"
-            to="/orders"
+            :to="`/${activeLang}/orders`"
             class="flex items-center gap-2 text-muted-foreground hover:text-foreground py-2"
             @click="mobileOpen = false"
           >
@@ -136,10 +136,10 @@
               </Button>
             </template>
             <template v-else>
-              <Button variant="ghost" class="justify-start" :as="RouterLink" to="/auth" @click="mobileOpen = false">
+              <Button variant="ghost" class="justify-start" :as="RouterLink" :to="`/${activeLang}/auth`" @click="mobileOpen = false">
                 {{ t("nav.logIn") }}
               </Button>
-              <Button variant="default" :as="RouterLink" to="/auth" @click="mobileOpen = false">
+              <Button variant="default" :as="RouterLink" :to="`/${activeLang}/auth`" @click="mobileOpen = false">
                 {{ t("nav.register") }}
               </Button>
             </template>
@@ -160,21 +160,23 @@ import Logo from "./Logo.vue";
 import LanguageSwitcher from "./LanguageSwitcher.vue";
 import Button from "./ui/button.vue";
 import { useAuthStore } from "@/stores/auth";
+import { currentLanguage } from "@/i18n";
 
 const { t } = useI18n();
 const router = useRouter();
 const authStore = useAuthStore();
 const mobileOpen = ref(false);
+const activeLang = computed(() => currentLanguage());
 
 const isLoggedIn = computed(() => !!authStore.user);
 const isAdmin = computed(() => authStore.user?.role === "admin");
 
 const navLinks = computed(() => [
-  { label: t("nav.services"), href: "/#services", isInternal: false },
-  { label: t("nav.materials"), href: "/#materials", isInternal: false },
-  { label: t("nav.howItWorks"), href: "/#process", isInternal: false },
-  { label: t("nav.portfolio"), href: "/portfolio", isInternal: true },
-  { label: t("nav.philosophy"), href: "/#philosophy", isInternal: false },
+  { label: t("nav.services"), href: `/${activeLang.value}/#services`, isInternal: true },
+  { label: t("nav.materials"), href: `/${activeLang.value}/#materials`, isInternal: true },
+  { label: t("nav.howItWorks"), href: `/${activeLang.value}/#process`, isInternal: true },
+  { label: t("nav.portfolio"), href: `/${activeLang.value}/portfolio`, isInternal: true },
+  { label: t("nav.philosophy"), href: `/${activeLang.value}/#philosophy`, isInternal: true },
 ]);
 
 async function handleLogout() {

+ 11 - 2
src/components/LanguageSwitcher.vue

@@ -34,10 +34,11 @@
 
 <script setup lang="ts">
 import { ref, computed } from "vue";
+import { useRouter, useRoute } from "vue-router";
 import { onClickOutside } from "@vueuse/core";
 import { Globe, ChevronDown } from "lucide-vue-next";
 import Button from "./ui/button.vue";
-import { setLanguage, currentLanguage } from "@/i18n";
+import { currentLanguage } from "@/i18n";
 
 const languages = [
   { code: "en", label: "English", flag: "🇬🇧" },
@@ -46,6 +47,8 @@ const languages = [
   { code: "me", label: "Crnogorski", flag: "🇲🇪" },
 ];
 
+const router = useRouter();
+const route = useRoute();
 const isOpen = ref(false);
 const containerRef = ref<HTMLElement | null>(null);
 const currentLang = computed(
@@ -57,8 +60,14 @@ onClickOutside(containerRef, () => (isOpen.value = false));
 const emit = defineEmits(["select"]);
 
 function changeLang(code: string) {
-  setLanguage(code);
   isOpen.value = false;
+  // Push the new language to the current route
+  router.push({ 
+    name: route.name || undefined, 
+    params: { ...route.params, lang: code },
+    query: route.query,
+    hash: route.hash
+  });
   emit("select");
 }
 </script>

+ 7 - 7
src/components/OrderTracker.vue

@@ -49,16 +49,16 @@ const { t } = useI18n();
 const steps = computed(() => {
   if (props.status === 'cancelled') {
     return [
-      { id: 'pending', icon: Clock, label: 'Pending' },
-      { id: 'cancelled', icon: XCircle, label: 'Cancelled' }
+      { id: 'pending', icon: Clock, label: t('statuses.pending') },
+      { id: 'cancelled', icon: XCircle, label: t('statuses.cancelled') }
     ];
   }
   return [
-    { id: 'pending', icon: Clock, label: 'Pending' },
-    { id: 'processing', icon: ShieldCheck, label: 'Approved' },
-    { id: 'printing', icon: Printer, label: 'Printing' },
-    { id: 'shipped', icon: Truck, label: 'Shipped' },
-    { id: 'completed', icon: PackageCheck, label: 'Delivered' }
+    { id: 'pending', icon: Clock, label: t('statuses.pending') },
+    { id: 'processing', icon: ShieldCheck, label: t('statuses.approved') },
+    { id: 'printing', icon: Printer, label: t('statuses.printing') },
+    { id: 'shipped', icon: Truck, label: t('statuses.shipped') },
+    { id: 'completed', icon: PackageCheck, label: t('statuses.delivered') }
   ];
 });
 

+ 2 - 2
src/components/PrintingNuancesSection.vue

@@ -17,8 +17,8 @@
                 <component :is="nuance.icon" class="w-6 h-6 text-primary" />
               </div>
               <div>
-                <h3 class="font-display text-lg font-semibold mb-2">{{ nuance.title }}</h3>
-                <p class="text-sm text-muted-foreground leading-relaxed">{{ nuance.description }}</p>
+                <h3 class="font-display text-xl font-black mb-2 group-hover:text-primary transition-colors">{{ nuance.title }}</h3>
+                <p class="text-sm text-muted-foreground leading-relaxed font-medium">{{ nuance.description }}</p>
               </div>
             </div>
             

+ 1 - 1
src/components/ProcessSection.vue

@@ -1,5 +1,5 @@
 <template>
-  <section id="process" class="py-12 sm:py-24 relative overflow-hidden bg-card/30">
+  <section id="process" class="py-12 sm:py-24 relative overflow-hidden bg-card/30 scroll-mt-24">
     <div class="container mx-auto px-4">
       <div class="text-center mb-10 sm:mb-16 animate-slide-up">
         <span class="text-primary font-display text-sm tracking-widest uppercase">{{ t("nav.howItWorks") }}</span>

+ 1 - 1
src/components/QuotingSection.vue

@@ -1,5 +1,5 @@
 <template>
-  <section id="philosophy" class="py-12 sm:py-20 bg-white relative overflow-hidden">
+  <section id="philosophy" class="py-12 sm:py-20 bg-white relative overflow-hidden scroll-mt-24">
     <div class="absolute inset-0 bg-gradient-glow opacity-30" />
 
     <div class="container mx-auto px-4 relative z-10">

+ 12 - 7
src/components/ServicesSection.vue

@@ -1,5 +1,5 @@
 <template>
-  <section id="services" class="py-12 sm:py-20 bg-white relative overflow-hidden">
+  <section id="services" class="py-12 sm:py-20 bg-white relative overflow-hidden scroll-mt-24">
     <div class="absolute inset-0 grid-pattern opacity-30" />
 
     <div v-if="isLoading" class="py-24 flex items-center justify-center">
@@ -26,30 +26,34 @@
           <div class="w-12 h-12 bg-primary/5 rounded-xl flex items-center justify-center mb-4 group-hover:bg-primary group-hover:text-primary-foreground transition-all duration-500">
             <component :is="iconMap[service.tech_type] || iconMap.DEFAULT" class="w-6 h-6" />
           </div>
-          <h3 class="font-display text-lg font-bold mb-2">{{ service[`name_${locale}` as keyof Service] || service.name_en }}</h3>
+          <h3 class="font-display text-xl font-black mb-2 group-hover:text-primary transition-colors tracking-tight">
+            {{ service[`name_${locale}` as keyof Service] || service.name_en }}
+          </h3>
           <p class="text-foreground/50 text-sm leading-relaxed font-medium">{{ service[`desc_${locale}` as keyof Service] || service.desc_en }}</p>
         </div>
       </div>
 
       <!-- Materials -->
-      <div id="materials" class="mt-10 sm:mt-16">
+      <div id="materials" class="mt-10 sm:mt-16 scroll-mt-24">
         <div class="flex items-center gap-3 mb-8">
           <div class="w-8 h-8 bg-primary/5 rounded-lg flex items-center justify-center">
             <Sparkles class="w-4 h-4 text-primary" />
           </div>
           <h3 class="font-display text-xl font-extrabold tracking-tight">{{ t("pricing.materials") }}</h3>
         </div>
-        <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
+        <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
           <div
             v-for="(material, idx) in materials"
             :key="material.id"
-            class="p-4 bg-white border border-black/[0.04] rounded-2xl hover:border-primary/20 transition-all hover:shadow-sm group animate-fade-in"
+            class="group p-8 card-apple hover:border-primary/20 animate-fade-in"
             :style="{ animationDelay: `${idx * 50}ms` }"
           >
-            <div class="text-sm font-display font-extrabold text-foreground mb-1 group-hover:text-primary transition-colors uppercase tracking-wide">
+            <div class="text-xl font-display font-black text-foreground mb-3 group-hover:text-primary transition-colors uppercase tracking-widest border-b border-black/[0.03] pb-2">
               {{ material[`name_${locale}` as keyof Material] || material.name_en }}
             </div>
-            <p class="text-[11px] text-foreground/40 line-clamp-2 leading-tight font-medium">{{ material[`desc_${locale}` as keyof Material] || material.desc_en }}</p>
+            <p class="text-sm text-foreground/50 leading-relaxed font-medium whitespace-pre-line">
+              {{ material[`long_desc_${locale}` as keyof Material] || material[`desc_${locale}` as keyof Material] || material.desc_en }}
+            </p>
           </div>
         </div>
       </div>
@@ -75,6 +79,7 @@ interface Material {
   id: number; 
   name_en: string; name_ru: string; name_me: string;
   desc_en: string; desc_ru: string; desc_me: string;
+  long_desc_en?: string; long_desc_ru?: string; long_desc_ua?: string; long_desc_me?: string;
   price_per_cm3: number 
 }
 

+ 17 - 1
src/i18n.ts

@@ -18,9 +18,25 @@ const i18n = createI18n({
   }
 });
 
-export const setLanguage = (lang: string) => {
+export const loadAdminTranslations = async (lang?: string) => {
+  const targetLang = lang || (i18n.global as any).locale.value;
+  try {
+    // Dynamic import to keep admin translations in a separate chunk
+    const messages = await import(`./locales/${targetLang}.admin.json`);
+    i18n.global.mergeLocaleMessage(targetLang, messages.default);
+  } catch (error) {
+    console.error(`Failed to load admin translations for ${targetLang}`, error);
+  }
+};
+
+export const setLanguage = async (lang: string) => {
   (i18n.global as any).locale.value = lang;
   localStorage.setItem('locale', lang);
+  
+  // If we are in an admin-related path, we might want to load admin translations for the new language
+  if (window.location.pathname.startsWith('/admin')) {
+    await loadAdminTranslations(lang);
+  }
 };
 export const currentLanguage = () => (i18n.global as any).locale.value as string;
 export default i18n;

+ 46 - 6
src/lib/api.ts

@@ -75,11 +75,12 @@ export const submitOrder = async (orderData: FormData) => {
   return response.json();
 };
 
-export const getMyOrders = async () => {
+export const getMyOrders = async (page = 1, size = 10) => {
   const token = localStorage.getItem("token");
-  if (!token) return [];
-
-  const response = await fetch(`${API_BASE_URL}/orders/my?lang=${i18n.global.locale.value}`, {
+  if (!token) return { orders: [], total: 0 };
+  
+  const query = new URLSearchParams({ page: page.toString(), size: size.toString() });
+  const response = await fetch(`${API_BASE_URL}/orders/my?${query.toString()}&lang=${i18n.global.locale.value}`, {
     headers: {
       'Authorization': `Bearer ${token}`
     }
@@ -209,9 +210,15 @@ export const resetPassword = async (data: any) => {
   return response.json();
 };
 
-export const adminGetOrders = async () => {
+export const adminGetOrders = async (filters: { search?: string, status?: string, date_from?: string, date_to?: string } = {}) => {
   const token = localStorage.getItem("token");
-  const response = await fetch(`${API_BASE_URL}/orders/admin/list?lang=${i18n.global.locale.value}`, {
+  const query = new URLSearchParams();
+  if (filters.search) query.append("search", filters.search);
+  if (filters.status) query.append("status", filters.status);
+  if (filters.date_from) query.append("date_from", filters.date_from);
+  if (filters.date_to) query.append("date_to", filters.date_to);
+  
+  const response = await fetch(`${API_BASE_URL}/orders/admin/list?${query.toString()}&lang=${i18n.global.locale.value}`, {
     headers: { 'Authorization': `Bearer ${token}` }
   });
   if (!response.ok) throw new Error("Failed to fetch admin orders");
@@ -232,6 +239,16 @@ export const adminUpdateOrder = async (orderId: number, data: any) => {
   return response.json();
 };
 
+export const adminDeleteOrder = async (orderId: number) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/orders/${orderId}/admin?lang=${i18n.global.locale.value}`, {
+    method: 'DELETE',
+    headers: { 'Authorization': `Bearer ${token}` }
+  });
+  if (!response.ok) throw new Error("Failed to delete order");
+  return response.json();
+};
+
 export const getPriceEstimate = async (data: any) => {
   const response = await fetch(`${API_BASE_URL}/orders/estimate?lang=${i18n.global.locale.value}`, {
     method: 'POST',
@@ -563,3 +580,26 @@ export const adminGetAuditLogs = async (page = 1, size = 50, action = "") => {
   if (!response.ok) throw new Error("Failed to fetch audit logs");
   return response.json();
 };
+
+export const adminGetOrderItems = async (orderId: number) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/orders/${orderId}/items?lang=${i18n.global.locale.value}`, {
+    headers: { 'Authorization': `Bearer ${token}` }
+  });
+  if (!response.ok) throw new Error("Failed to fetch order items");
+  return response.json();
+};
+
+export const adminUpdateOrderItems = async (orderId: number, items: any[]) => {
+  const token = localStorage.getItem("token");
+  const response = await fetch(`${API_BASE_URL}/orders/${orderId}/items?lang=${i18n.global.locale.value}`, {
+    method: 'PUT',
+    headers: { 
+      'Content-Type': 'application/json',
+      'Authorization': `Bearer ${token}`
+    },
+    body: JSON.stringify(items)
+  });
+  if (!response.ok) throw new Error("Failed to update order items");
+  return response.json();
+};

+ 141 - 0
src/locales/en.admin.json

@@ -0,0 +1,141 @@
+{
+  "admin": {
+    "actions": {
+      "activateAccount": "Activate account",
+      "allowChat": "Allow Chat",
+      "cancel": "Cancel",
+      "create": "Create",
+      "delete": "Delete",
+      "deleteFile": "Delete attached file",
+      "edit": "Edit",
+      "forbidChat": "Forbid Chat",
+      "makePrivate": "Make Private",
+      "makePublic": "Make Public",
+      "printInvoice": "Print Final Invoice (Faktura)",
+      "printProforma": "Print Proforma (Predračun/Uplatnica)",
+      "save": "Save Changes",
+      "saveChanges": "Save Changes",
+      "savePrice": "Save Price",
+      "sending": "Sending...",
+      "suspendAccount": "Suspend account",
+      "toggleAdminRole": "Toggle Admin role",
+      "viewOriginal": "View Original Snapshot"
+    },
+    "addNew": "Add New",
+    "allStatuses": "All Statuses",
+    "dashboard": "Dashboard",
+    "fields": {
+      "action": "Action",
+      "active": "Active and Visible",
+      "category": "Category",
+      "colors": "Available Colors",
+      "content": "Content",
+      "customColorDirInfo": "Custom Color (No directory info)",
+      "customColorInfo": "Custom Color (No directory info)",
+      "customColorPlaceholder": "Custom color...",
+      "defaultColor": "Default Color",
+      "description": "Description",
+      "details": "Details",
+      "email": "Email Address",
+      "estimated": "Estimated",
+      "excerpt": "Excerpt",
+      "externalLink": "External Model Link",
+      "finalPrice": "Final Price",
+      "firstName": "First Name",
+      "imageUrl": "Image URL",
+      "lastName": "Last Name",
+      "name": "Name",
+      "noPhotos": "No photos yet",
+      "noPortfolio": "Do not publish in portfolio",
+      "noUsers": "No users found",
+      "notifyUser": "Notify User",
+      "originalSnapshot": "Original Snapshot",
+      "password": "Password",
+      "phone": "Phone Number",
+      "photoReport": "Photo Report",
+      "portfolioAllowed": "Portfolio Allowed",
+      "price": "Price per cm³",
+      "pricePerCm3": "Price / cm³",
+      "projectNotes": "Project Notes",
+      "publishImmediately": "Publish immediately",
+      "quantity": "Quantity",
+      "selectColorStrict": "Select Color (Strict)",
+      "selectMaterialStrict": "Select Material (Strict)",
+      "shippingAddress": "Shipping Address",
+      "slug": "Slug (URL)",
+      "snapshotInfo": "These are the parameters recorded at the moment of order submission.",
+      "sourceFiles": "Source Files",
+      "strictSelectionInfo": "Prices and options are derived strictly from the catalog.",
+      "target": "Target",
+      "techType": "Technology Type",
+      "timestamp": "Timestamp",
+      "title": "Title",
+      "updateFinalPrice": "Update Final Price",
+      "user": "User"
+    },
+    "labels": {
+      "actions": "Actions",
+      "chat": "Chat",
+      "contact": "Contact",
+      "registered": "Registered",
+      "role": "Role",
+      "user": "User"
+    },
+    "managementCenter": "Management Center",
+    "modals": {
+      "changeParams": "Change Material & Color",
+      "createMaterial": "Create New Material",
+      "createPost": "Create New Post",
+      "createService": "Create New Service",
+      "createUser": "Create New User",
+      "editMaterial": "Edit Material",
+      "editPost": "Edit Blog Post",
+      "editService": "Edit Service",
+      "editUser": "Edit User"
+    },
+    "questions": {
+      "deletePhoto": "Are you sure you want to delete this photo?"
+    },
+    "searchPlaceholder": "Search {tab}...",
+    "searchUsersPlaceholder": "Search by name, email, phone...",
+    "tabs": {
+      "audit": "Audit",
+      "blog": "Blog",
+      "materials": "Materials",
+      "orders": "Orders",
+      "portfolio": "Portfolio",
+      "posts": "Blog",
+      "services": "Services",
+      "users": "Users"
+    },
+    "toasts": {
+      "chatDisabled": "Chat for User #{id} is now Disabled",
+      "chatEnabled": "Chat for User #{id} is now Enabled",
+      "fileAttached": "File attached and preview generated",
+      "fileDeleted": "File deleted successfully",
+      "genericError": "Operation failed",
+      "invoiceReminder": "Invoice generated. Don't forget to print and attach it to the package!",
+      "loadError": "Failed to load {tab}",
+      "materialDeleted": "Material deleted",
+      "materialSaved": "Material saved",
+      "noConsent": "User did not consent to portfolio",
+      "paramsUpdated": "Parameters updated",
+      "photoAdded": "Photo added",
+      "photoDeleted": "Photo deleted successfully",
+      "postDeleted": "Article deleted",
+      "postSaved": "Article saved",
+      "priceUpdated": "Price updated",
+      "roleUpdated": "Role for User #{id} updated to {role}",
+      "serviceDeleted": "Service deleted",
+      "serviceSaved": "Service saved",
+      "statusUpdated": "Status → {status}",
+      "userCreated": "User created successfully",
+      "userSaved": "User saved"
+    },
+    "total": "Total",
+    "filters": "Filters",
+    "from": "From",
+    "to": "To",
+    "reset": "Reset"
+  }
+}

+ 33 - 157
src/locales/en.json

@@ -39,163 +39,24 @@
       },
       "title": "Our Values",
       "trust": {
-        "content": "We trust you to value our work.",
+        "content": "We do our best to make you satisfied.",
         "title": "Trust"
       }
     }
   },
-  "admin": {
-    "actions": {
-      "cancel": "Cancel",
-      "create": "Create",
-      "delete": "Delete",
-      "deleteFile": "Delete attached file",
-      "edit": "Edit",
-      "printInvoice": "Print Uplatnica",
-      "save": "Save Changes",
-      "savePrice": "Save Price",
-      "sending": "Sending...",
-      "toggleAdminRole": "Toggle Admin role",
-      "viewOriginal": "View Original Snapshot",
-      "saveChanges": "Save Changes",
-      "suspendAccount": "Suspend account",
-      "activateAccount": "Activate account",
-      "makePublic": "Make Public",
-      "makePrivate": "Make Private",
-      "allowChat": "Allow Chat",
-      "forbidChat": "Forbid Chat"
-    },
-    "addNew": "Add New",
-    "allStatuses": "All Statuses",
-    "dashboard": "Dashboard",
-    "fields": {
-      "active": "Active and Visible",
-      "category": "Category",
-      "colors": "Available Colors",
-      "content": "Content",
-      "customColorInfo": "Custom Color (No directory info)",
-      "defaultColor": "Default Color",
-      "description": "Description",
-      "email": "Email Address",
-      "estimated": "Estimated",
-      "excerpt": "Excerpt",
-      "externalLink": "External Model Link",
-      "finalPrice": "Final Price",
-      "firstName": "First Name",
-      "imageUrl": "Image URL",
-      "lastName": "Last Name",
-      "name": "Name",
-      "noPhotos": "No photos yet",
-      "noPortfolio": "Do not publish in portfolio",
-      "noUsers": "No users found",
-      "notifyUser": "Notify User",
-      "originalSnapshot": "Original Snapshot",
-      "password": "Password",
-      "phone": "Phone Number",
-      "photoReport": "Photo Report",
-      "portfolioAllowed": "Portfolio Allowed",
-      "price": "Price per cm³",
-      "pricePerCm3": "Price / cm³",
-      "projectNotes": "Project Notes",
-      "publishImmediately": "Publish immediately",
-      "quantity": "Quantity",
-      "selectColorStrict": "Select Color (Strict)",
-      "selectMaterialStrict": "Select Material (Strict)",
-      "shippingAddress": "Shipping Address",
-      "slug": "Slug (URL)",
-      "snapshotInfo": "These are the parameters recorded at the moment of order submission.",
-      "sourceFiles": "Source Files",
-      "strictSelectionInfo": "Strict selection ensures consistency with the material handbook.",
-      "techType": "Technology Type",
-      "title": "Title",
-      "customColorDirInfo": "Custom Color (No directory info)",
-      "customColorPlaceholder": "Custom color...",
-      "timestamp": "Timestamp",
-      "action": "Action",
-      "target": "Target",
-      "details": "Details",
-      "user": "User"
-    },
-    "labels": {
-      "actions": "Actions",
-      "chat": "Chat",
-      "contact": "Contact",
-      "registered": "Registered",
-      "role": "Role",
-      "user": "User"
-    },
-    "managementCenter": "Management Center",
-    "modals": {
-      "createMaterial": "Create New Material",
-      "createPost": "Create New Post",
-      "createService": "Create New Service",
-      "createUser": "Create New User",
-      "editMaterial": "Edit Material",
-      "editPost": "Edit Blog Post",
-      "editService": "Edit Service",
-      "editUser": "Edit User",
-      "changeParams": "Change Material & Color"
-    },
-    "searchPlaceholder": "Search {tab}...",
-    "searchUsersPlaceholder": "Search by name, email, phone...",
-    "toasts": {
-      "chatDisabled": "Chat for User #{id} is now Disabled",
-      "chatEnabled": "Chat for User #{id} is now Enabled",
-      "fileAttached": "File attached and preview generated",
-      "fileDeleted": "File deleted successfully",
-      "genericError": "Operation failed",
-      "loadError": "Failed to load {tab}",
-      "materialDeleted": "Material deleted",
-      "materialSaved": "Material saved",
-      "noConsent": "User did not consent to portfolio",
-      "paramsUpdated": "Parameters updated",
-      "photoAdded": "Photo added",
-      "postDeleted": "Article deleted",
-      "postSaved": "Article saved",
-      "priceUpdated": "Price updated",
-      "roleUpdated": "Role for User #{id} updated to {role}",
-      "serviceDeleted": "Service deleted",
-      "serviceSaved": "Service saved",
-      "statusUpdated": "Status → {status}",
-      "userCreated": "User created successfully",
-      "userSaved": "User saved",
-      "photoDeleted": "Photo deleted successfully"
-    },
-    "total": "Total",
-    "statuses": {
-      "pending": "Pending",
-      "processing": "Processing",
-      "shipped": "Shipped",
-      "completed": "Completed",
-      "cancelled": "Cancelled"
-    },
-    "tabs": {
-      "orders": "Orders",
-      "materials": "Materials",
-      "services": "Services",
-      "users": "Users",
-      "posts": "Blog",
-      "portfolio": "Portfolio",
-      "blog": "Blog",
-      "audit": "Audit"
-    },
-    "questions": {
-      "deletePhoto": "Are you sure you want to delete this photo?"
-    }
-  },
   "auth": {
     "back": "Back to Home",
     "fields": {
-      "confirmPassword": "Confirm Password",
-      "email": "Email",
-      "newPassword": "New Password",
-      "password": "Password",
       "accountType": "Account Type",
-      "individual": "Individual",
       "company": "Company",
+      "companyAddress": "Company HQ Address",
       "companyName": "Company Name",
       "companyPIB": "Tax ID (PIB)",
-      "companyAddress": "Company HQ Address"
+      "confirmPassword": "Confirm Password",
+      "email": "Email",
+      "individual": "Individual",
+      "newPassword": "New Password",
+      "password": "Password"
     },
     "forgot": {
       "link": "Forgot Password?",
@@ -346,12 +207,12 @@
     "unread": "New message"
   },
   "common": {
-    "save_continue": "Save and Continue",
-    "or": "or",
     "back": "Back",
-    "pending": "Pending...",
     "default": "Default",
-    "orderId": "Order #{id}"
+    "or": "or",
+    "orderId": "Order #{id}",
+    "pending": "Pending...",
+    "save_continue": "Save and Continue"
   },
   "contact": {
     "form": {
@@ -407,7 +268,7 @@
     "privacy": "Privacy",
     "services": "Services",
     "support": "Support",
-    "tagline": "Radionica 3D — A service built on trust. We bring your ideas to life, you value our craftsmanship.",
+    "tagline": "Radionica 3D — A service built on trust. We do our best to make sure you are satisfied.",
     "terms": "Terms"
   },
   "guidelines": {
@@ -580,7 +441,7 @@
     },
     "faq": {
       "q1": {
-        "answer": "You decide the value.",
+        "answer": "We do our best to make you satisfied.",
         "question": "How much should I pay?"
       },
       "q2": {
@@ -608,7 +469,7 @@
   },
   "hero": {
     "badge": "Trust in Every Layer",
-    "description": "A unique 3D printing service: send us a model, receive it by mail, and pay what you think it's worth.",
+    "description": "Unique 3D printing service in Montenegro: send us a model, receive it by mail with payment on delivery.",
     "pricingButton": "How It Works",
     "stats": {
       "materials": "Materials",
@@ -619,7 +480,7 @@
       "shippingValue": "Express"
     },
     "title": "We Print —",
-    "titleGradient": "You Value",
+    "titleGradient": "We Care",
     "uploadButton": "Order Print"
   },
   "nav": {
@@ -728,7 +589,7 @@
       "step1": "Send us an STL model or a link",
       "step2": "We'll craft it using the best material",
       "step3": "Receive the package at your address",
-      "step4": "Evaluate our work and pay your price"
+      "step4": "We do our best to make sure you are satisfied."
     }
   },
   "privacy": {
@@ -846,7 +707,7 @@
       "title": "Payment",
       "trustModel": {
         "point1": "Pay after delivery.",
-        "point2": "You value quality.",
+        "point2": "We guarantee quality.",
         "point3": "Fair usage.",
         "point4": "Support provided.",
         "title": "Details"
@@ -925,9 +786,24 @@
       "noCommissions": "No fees",
       "noPrepayment": "No prepayment",
       "shipping": "Mail delivery",
-      "yourPrice": "Your price"
+      "yourPrice": "Fair Price"
     },
     "title": "Why we",
     "titleItalic": "trust"
+  },
+  "statuses": {
+    "pending": "Pending",
+    "processing": "Processing",
+    "shipped": "Shipped",
+    "completed": "Completed",
+    "cancelled": "Cancelled",
+    "approved": "Approved",
+    "printing": "Printing",
+    "delivered": "Delivered"
+  },
+  "admin": {
+    "actions": {
+      "deleteOrder": "Delete Order Entirely"
+    }
   }
 }

+ 141 - 0
src/locales/me.admin.json

@@ -0,0 +1,141 @@
+{
+  "admin": {
+    "actions": {
+      "activateAccount": "Aktiviraj nalog",
+      "allowChat": "Dozvoli čet",
+      "cancel": "Otkaži",
+      "create": "Kreiraj",
+      "delete": "Obriši",
+      "deleteFile": "Obriši zakačeni fajl",
+      "edit": "Uredi",
+      "forbidChat": "Zabrani čet",
+      "makePrivate": "Učini privatnim",
+      "makePublic": "Učini javnim",
+      "printInvoice": "Odštampaj fakturu",
+      "printProforma": "Odštampaj predračun",
+      "save": "Sačuvaj izmjene",
+      "saveChanges": "Sačuvaj promjene",
+      "savePrice": "Sačuvaj cijenu",
+      "sending": "Slanje...",
+      "suspendAccount": "Suspenduj nalog",
+      "toggleAdminRole": "Promijeni admin ulogu",
+      "viewOriginal": "Vidi originalne parametre"
+    },
+    "addNew": "Dodaj novo",
+    "allStatuses": "Svi statusi",
+    "dashboard": "Panel",
+    "fields": {
+      "action": "Akcija",
+      "active": "Aktivan i vidljiv",
+      "category": "Kategorija",
+      "colors": "Dostupne boje",
+      "content": "Sadržaj",
+      "customColorDirInfo": "Prilagođena boja (nema info u direktorijumu)",
+      "customColorInfo": "Prilagođena boja (bez informacija iz kataloga)",
+      "customColorPlaceholder": "Prilagođena boja...",
+      "defaultColor": "Podrazumijevana boja",
+      "description": "Opis",
+      "details": "Detalji",
+      "email": "Email adresa",
+      "estimated": "Procjena",
+      "excerpt": "Kratak opis",
+      "externalLink": "Link ka modelu",
+      "finalPrice": "Konačna cijena",
+      "firstName": "Ime",
+      "imageUrl": "URL slike",
+      "lastName": "Prezime",
+      "name": "Naziv",
+      "noPhotos": "Nema fotografija",
+      "noPortfolio": "Ne objavljivati u portfoliju",
+      "noUsers": "Korisnici nisu pronađeni",
+      "notifyUser": "Obavijesti korisnika",
+      "originalSnapshot": "Originalni parametri",
+      "password": "Lozinka",
+      "phone": "Broj telefona",
+      "photoReport": "Foto izvještaj",
+      "portfolioAllowed": "Portfolio dozvoljen",
+      "price": "Cijena po cm³",
+      "pricePerCm3": "Cijena / cm³",
+      "projectNotes": "Napomene o projektu",
+      "publishImmediately": "Objavi odmah",
+      "quantity": "Količina",
+      "selectColorStrict": "Izaberi boju",
+      "selectMaterialStrict": "Izaberi materijal",
+      "shippingAddress": "Adresa za dostavu",
+      "slug": "Slug (URL)",
+      "snapshotInfo": "Ovo su parametri zabilježeni u trenutku slanja narudžbe.",
+      "sourceFiles": "Izvorni fajlovi",
+      "strictSelectionInfo": "Cijene i opcije se izvode strogo iz kataloga.",
+      "target": "Cilj",
+      "techType": "Tip tehnologije",
+      "timestamp": "Vrijeme",
+      "title": "Naslov",
+      "updateFinalPrice": "Ažuriraj konačnu cijenu",
+      "user": "Korisnik"
+    },
+    "labels": {
+      "actions": "Akcije",
+      "chat": "Čat",
+      "contact": "Kontakt",
+      "registered": "Registrovan",
+      "role": "Uloga",
+      "user": "Korisnik"
+    },
+    "managementCenter": "Centar za upravljanje",
+    "modals": {
+      "changeParams": "Promijeni materijal i boju",
+      "createMaterial": "Novi materijal",
+      "createPost": "Novi članak",
+      "createService": "Nova usluga",
+      "createUser": "Novi korisnik",
+      "editMaterial": "Uredi materijal",
+      "editPost": "Uredi članak",
+      "editService": "Uredi uslugu",
+      "editUser": "Uredi korisnika"
+    },
+    "questions": {
+      "deletePhoto": "Da li ste sigurni da želite obrisati ovu fotografiju?"
+    },
+    "searchPlaceholder": "Traži {tab}...",
+    "searchUsersPlaceholder": "Traži po imenu, emailu, telefonu...",
+    "tabs": {
+      "audit": "Audit",
+      "blog": "Blog",
+      "materials": "Materijali",
+      "orders": "Narudžbe",
+      "portfolio": "Portfolio",
+      "posts": "Blog",
+      "services": "Usluge",
+      "users": "Korisnici"
+    },
+    "toasts": {
+      "chatDisabled": "Čat za korisnika #{id} je ONEMOGUĆEN",
+      "chatEnabled": "Čat za korisnika #{id} je OMOGUĆEN",
+      "fileAttached": "Fajl okačen i pregled generisan",
+      "fileDeleted": "Fajl uspješno obrisan",
+      "genericError": "Operacija nije uspjela",
+      "invoiceReminder": "Faktura je generisana. Ne zaboravite da je odštampate i priložite uz paket!",
+      "loadError": "Greška pri učitavanju {tab}",
+      "materialDeleted": "Materijal obrisan",
+      "materialSaved": "Materijal sačuvan",
+      "noConsent": "Korisnik nije dao saglasnost za portfolio",
+      "paramsUpdated": "Parametri ažurirani",
+      "photoAdded": "Fotografija dodata",
+      "photoDeleted": "Fotografija uspješno obrisana",
+      "postDeleted": "Članak obrisan",
+      "postSaved": "Članak sačuvan",
+      "priceUpdated": "Cijena ažurirana",
+      "roleUpdated": "Uloga korisnika #{id} promijenjena u {role}",
+      "serviceDeleted": "Usluga obrisana",
+      "serviceSaved": "Usluga sačuvana",
+      "statusUpdated": "Status → {status}",
+      "userCreated": "Korisnik uspješno kreiran",
+      "userSaved": "Korisnik sačuvan"
+    },
+    "total": "Ukupno",
+    "filters": "Filteri",
+    "from": "Od",
+    "to": "Do",
+    "reset": "Resetuj"
+  }
+}

+ 33 - 157
src/locales/me.json

@@ -39,163 +39,24 @@
       },
       "title": "Naše vrijednosti",
       "trust": {
-        "content": "Vjerujemo da ćete cijeniti naš rad.",
+        "content": "Trudimo se da budete zadovoljni.",
         "title": "Povjerenje"
       }
     }
   },
-  "admin": {
-    "actions": {
-      "cancel": "Otkaži",
-      "create": "Kreiraj",
-      "delete": "Obriši",
-      "deleteFile": "Obriši zakačeni fajl",
-      "edit": "Uredi",
-      "printInvoice": "Odštampaj uplatnicu",
-      "save": "Sačuvaj izmjene",
-      "savePrice": "Sačuvaj cijenu",
-      "sending": "Slanje...",
-      "toggleAdminRole": "Promijeni admin ulogu",
-      "viewOriginal": "Vidi originalne parametre",
-      "saveChanges": "Sačuvaj promjene",
-      "suspendAccount": "Suspenduj nalog",
-      "activateAccount": "Aktiviraj nalog",
-      "makePublic": "Učini javnim",
-      "makePrivate": "Učini privatnim",
-      "allowChat": "Dozvoli čet",
-      "forbidChat": "Zabrani čet"
-    },
-    "addNew": "Dodaj novo",
-    "allStatuses": "Svi statusi",
-    "dashboard": "Panel",
-    "fields": {
-      "active": "Aktivan i vidljiv",
-      "category": "Kategorija",
-      "colors": "Dostupne boje",
-      "content": "Sadržaj",
-      "customColorInfo": "Prilagođena boja (bez informacija iz kataloga)",
-      "defaultColor": "Podrazumijevana boja",
-      "description": "Opis",
-      "email": "Email adresa",
-      "estimated": "Procjena",
-      "excerpt": "Kratak opis",
-      "externalLink": "Link ka modelu",
-      "finalPrice": "Konačna cijena",
-      "firstName": "Ime",
-      "imageUrl": "URL slike",
-      "lastName": "Prezime",
-      "name": "Naziv",
-      "noPhotos": "Nema fotografija",
-      "noPortfolio": "Ne objavljivati u portfoliju",
-      "noUsers": "Korisnici nisu pronađeni",
-      "notifyUser": "Obavijesti korisnika",
-      "originalSnapshot": "Originalni parametri",
-      "password": "Lozinka",
-      "phone": "Broj telefona",
-      "photoReport": "Foto izvještaj",
-      "portfolioAllowed": "Portfolio dozvoljen",
-      "price": "Cijena po cm³",
-      "pricePerCm3": "Cijena / cm³",
-      "projectNotes": "Napomene o projektu",
-      "publishImmediately": "Objavi odmah",
-      "quantity": "Količina",
-      "selectColorStrict": "Izaberi boju",
-      "selectMaterialStrict": "Izaberi materijal",
-      "shippingAddress": "Adresa za dostavu",
-      "slug": "Slug (URL)",
-      "snapshotInfo": "Ovo su parametri zabilježeni u trenutku slanja narudžbe.",
-      "sourceFiles": "Izvorni fajlovi",
-      "strictSelectionInfo": "Striktni izbor osigurava dosljednost sa katalogom materijala.",
-      "techType": "Tip tehnologije",
-      "title": "Naslov",
-      "customColorDirInfo": "Prilagođena boja (nema info u direktorijumu)",
-      "customColorPlaceholder": "Prilagođena boja...",
-      "timestamp": "Vrijeme",
-      "action": "Akcija",
-      "target": "Cilj",
-      "details": "Detalji",
-      "user": "Korisnik"
-    },
-    "labels": {
-      "actions": "Akcije",
-      "chat": "Čat",
-      "contact": "Kontakt",
-      "registered": "Registrovan",
-      "role": "Uloga",
-      "user": "Korisnik"
-    },
-    "managementCenter": "Centar za upravljanje",
-    "modals": {
-      "createMaterial": "Novi materijal",
-      "createPost": "Novi članak",
-      "createService": "Nova usluga",
-      "createUser": "Novi korisnik",
-      "editMaterial": "Uredi materijal",
-      "editPost": "Uredi članak",
-      "editService": "Uredi uslugu",
-      "editUser": "Uredi korisnika",
-      "changeParams": "Promijeni materijal i boju"
-    },
-    "searchPlaceholder": "Traži {tab}...",
-    "searchUsersPlaceholder": "Traži po imenu, emailu, telefonu...",
-    "toasts": {
-      "chatDisabled": "Čat za korisnika #{id} je ONEMOGUĆEN",
-      "chatEnabled": "Čat za korisnika #{id} je OMOGUĆEN",
-      "fileAttached": "Fajl okačen i pregled generisan",
-      "fileDeleted": "Fajl uspješno obrisan",
-      "genericError": "Operacija nije uspjela",
-      "loadError": "Greška pri učitavanju {tab}",
-      "materialDeleted": "Materijal obrisan",
-      "materialSaved": "Materijal sačuvan",
-      "noConsent": "Korisnik nije dao saglasnost za portfolio",
-      "paramsUpdated": "Parametri ažurirani",
-      "photoAdded": "Fotografija dodata",
-      "postDeleted": "Članak obrisan",
-      "postSaved": "Članak sačuvan",
-      "priceUpdated": "Cijena ažurirana",
-      "roleUpdated": "Uloga korisnika #{id} promijenjena u {role}",
-      "serviceDeleted": "Usluga obrisana",
-      "serviceSaved": "Usluga sačuvana",
-      "statusUpdated": "Status → {status}",
-      "userCreated": "Korisnik uspješno kreiran",
-      "userSaved": "Korisnik sačuvan",
-      "photoDeleted": "Fotografija uspješno obrisana"
-    },
-    "total": "Ukupno",
-    "statuses": {
-      "pending": "Na čekanju",
-      "processing": "Obrada",
-      "shipped": "Poslato",
-      "completed": "Završeno",
-      "cancelled": "Otkazano"
-    },
-    "tabs": {
-      "orders": "Narudžbe",
-      "materials": "Materijali",
-      "services": "Usluge",
-      "users": "Korisnici",
-      "posts": "Blog",
-      "portfolio": "Portfolio",
-      "blog": "Blog",
-      "audit": "Audit"
-    },
-    "questions": {
-      "deletePhoto": "Da li ste sigurni da želite obrisati ovu fotografiju?"
-    }
-  },
   "auth": {
     "back": "Nazad na početnu",
     "fields": {
-      "confirmPassword": "Potvrdi lozinku",
-      "email": "Email",
-      "newPassword": "Nova lozinka",
-      "password": "Lozinka",
       "accountType": "Tip naloga",
-      "individual": "Fizičko lice",
       "company": "Pravno lice / Firma",
+      "companyAddress": "Adresa sjedišta",
       "companyName": "Naziv firme",
       "companyPIB": "PIB",
-      "companyAddress": "Adresa sjedišta"
+      "confirmPassword": "Potvrdi lozinku",
+      "email": "Email",
+      "individual": "Fizičko lice",
+      "newPassword": "Nova lozinka",
+      "password": "Lozinka"
     },
     "forgot": {
       "link": "Zaboravljena lozinka?",
@@ -346,12 +207,12 @@
     "unread": "Nova poruka"
   },
   "common": {
-    "save_continue": "Sačuvaj i nastavi",
-    "or": "ili",
     "back": "Nazad",
-    "pending": "Na čekanju...",
     "default": "Podrazumijevano",
-    "orderId": "Narudžba #{id}"
+    "or": "ili",
+    "orderId": "Narudžba #{id}",
+    "pending": "Na čekanju...",
+    "save_continue": "Sačuvaj i nastavi"
   },
   "contact": {
     "form": {
@@ -407,7 +268,7 @@
     "privacy": "Privatnost",
     "services": "Usluge",
     "support": "Podrška",
-    "tagline": "Radionica 3D — Servis izgrađen na povjerenju. Mi oživljavamo tvoje ideje, ti procjenjuješ naš rad.",
+    "tagline": "Radionica 3D — Servis izgrađen na povjerenju. Trudimo se da budete zadovoljni.",
     "terms": "Uslovi"
   },
   "guidelines": {
@@ -580,7 +441,7 @@
     },
     "faq": {
       "q1": {
-        "answer": "Vi odlučujete o vrijednosti.",
+        "answer": "Trudimo se da budete zadovoljni.",
         "question": "Koliko da platim?"
       },
       "q2": {
@@ -608,7 +469,7 @@
   },
   "hero": {
     "badge": "Povjerenje u svakom sloju",
-    "description": "Jedinstveni servis 3D štampe: pošaljite model, dobijte gotov proizvod poštom i platite onoliko koliko smatrate da vrijedi.",
+    "description": "Jedinstveni servis 3D štampe u Crnoj Gori: pošaljite model, dobijte gotov proizvod poštom s plaćanjem prilikom preuzimanja.",
     "pricingButton": "Kako funkcioniše",
     "stats": {
       "materials": "Materijala",
@@ -619,7 +480,7 @@
       "shippingValue": "Ekspres"
     },
     "title": "Mi štampamo —",
-    "titleGradient": "Vi procjenjujete",
+    "titleGradient": "Mi brinemo",
     "uploadButton": "Naruči štampu"
   },
   "nav": {
@@ -728,7 +589,7 @@
       "step1": "Pošaljite nam STL model ili link",
       "step2": "Mi ćemo ga izraditi od najboljeg materijala",
       "step3": "Primite paket na navedenu adresu",
-      "step4": "Procijenite naš rad i platite svoju cijenu"
+      "step4": "Dajemo sve od sebe da budete zadovoljni."
     }
   },
   "privacy": {
@@ -846,7 +707,7 @@
       "title": "Plaćanje",
       "trustModel": {
         "point1": "Platite nakon dostave.",
-        "point2": "Vi ocjenjujete kvalitet.",
+        "point2": "Garantujemo kvalitet.",
         "point3": "Fer upotreba.",
         "point4": "Podrška uključena.",
         "title": "Detalji"
@@ -925,9 +786,24 @@
       "noCommissions": "Bez provizija",
       "noPrepayment": "Bez uplate unaprijed",
       "shipping": "Isporuka poštom",
-      "yourPrice": "Tvoja cijena"
+      "yourPrice": "Časna cijena"
     },
     "title": "Zašto nam",
     "titleItalic": "vjeruju"
+  },
+  "statuses": {
+    "pending": "Na čekanju",
+    "processing": "U obradi",
+    "shipped": "Poslato",
+    "completed": "Završeno",
+    "cancelled": "Otkazano",
+    "approved": "Odobreno",
+    "printing": "Štampanje",
+    "delivered": "Uručeno"
+  },
+  "admin": {
+    "actions": {
+      "deleteOrder": "Obriši narudžbu trajno"
+    }
   }
 }

+ 141 - 0
src/locales/ru.admin.json

@@ -0,0 +1,141 @@
+{
+  "admin": {
+    "actions": {
+      "activateAccount": "Разблокировать аккаунт",
+      "allowChat": "Разрешить чат",
+      "cancel": "Отмена",
+      "create": "Создать",
+      "delete": "Удалить",
+      "deleteFile": "Удалить файл",
+      "edit": "Редактировать",
+      "forbidChat": "Запретить чат",
+      "makePrivate": "Сделать приватным",
+      "makePublic": "Сделать публичным",
+      "printInvoice": "Печать фактуры",
+      "printProforma": "Печать счета на оплату",
+      "save": "Сохранить",
+      "saveChanges": "Сохранить изменения",
+      "savePrice": "Сохранить цену",
+      "sending": "Отправка...",
+      "suspendAccount": "Заблокировать аккаунт",
+      "toggleAdminRole": "Переключить роль админа",
+      "viewOriginal": "Оригинал"
+    },
+    "addNew": "Добавить",
+    "allStatuses": "Все статусы",
+    "dashboard": "Дэшборд",
+    "fields": {
+      "action": "Действие",
+      "active": "Активен",
+      "category": "Категория",
+      "colors": "Цвета",
+      "content": "Контент",
+      "customColorDirInfo": "Своя цвет (нет инфо в справочнике)",
+      "customColorInfo": "Введите HEX или название",
+      "customColorPlaceholder": "Свой цвет...",
+      "defaultColor": "Цвет по умолчанию",
+      "description": "Описание",
+      "details": "Детали",
+      "email": "Email",
+      "estimated": "Оценка",
+      "excerpt": "Краткое описание",
+      "externalLink": "Внешняя ссылка",
+      "finalPrice": "Финальная цена",
+      "firstName": "Имя",
+      "imageUrl": "URL изображения",
+      "lastName": "Фамилия",
+      "name": "Название",
+      "noPhotos": "Нет фото",
+      "noPortfolio": "Не публиковать в портфолио",
+      "noUsers": "Пользователи не найдены",
+      "notifyUser": "Уведомить клиента",
+      "originalSnapshot": "Снимок заказа",
+      "password": "Пароль",
+      "phone": "Телефон",
+      "photoReport": "Фотоотчет",
+      "portfolioAllowed": "Разрешить в портфолио",
+      "price": "Цена",
+      "pricePerCm3": "Цена за см³",
+      "projectNotes": "Заметки к проекту",
+      "publishImmediately": "Опубликовать сразу",
+      "quantity": "Количество",
+      "selectColorStrict": "Строгий выбор цвета",
+      "selectMaterialStrict": "Строгий выбор материала",
+      "shippingAddress": "Адрес доставки",
+      "slug": "Slug (URL)",
+      "snapshotInfo": "Состояние заказа на момент создания",
+      "sourceFiles": "Исходные файлы",
+      "strictSelectionInfo": "Цены и опции берутся строго из каталога.",
+      "target": "Объект",
+      "techType": "Тип технологии",
+      "timestamp": "Время",
+      "title": "Заголовок",
+      "updateFinalPrice": "Обновить итоговую цену",
+      "user": "Пользователь"
+    },
+    "labels": {
+      "actions": "Действия",
+      "chat": "Чат",
+      "contact": "Контакт",
+      "registered": "Зарегистрирован",
+      "role": "Роль",
+      "user": "Пользователь"
+    },
+    "managementCenter": "Центр управления",
+    "modals": {
+      "changeParams": "Изменить материал и цвет",
+      "createMaterial": "Добавить материал",
+      "createPost": "Новая запись",
+      "createService": "Новая услуга",
+      "createUser": "Новый пользователь",
+      "editMaterial": "Редактировать материал",
+      "editPost": "Редактировать запись",
+      "editService": "Редактировать услугу",
+      "editUser": "Редактировать пользователя"
+    },
+    "questions": {
+      "deletePhoto": "Вы уверены, что хотите удалить это фото?"
+    },
+    "searchPlaceholder": "Поиск...",
+    "searchUsersPlaceholder": "Поиск пользователей...",
+    "tabs": {
+      "audit": "Аудит",
+      "blog": "Блог",
+      "materials": "Материалы",
+      "orders": "Заказы",
+      "portfolio": "Портфолио",
+      "posts": "Блог",
+      "services": "Услуги",
+      "users": "Пользователи"
+    },
+    "toasts": {
+      "chatDisabled": "Чат отключен",
+      "chatEnabled": "Чат включен",
+      "fileAttached": "Файл прикреплен",
+      "fileDeleted": "Файл удален",
+      "genericError": "Произошла ошибка",
+      "invoiceReminder": "Фактура создана. Не забудьте распечатать и приложить её к посылке!",
+      "loadError": "Ошибка загрузки",
+      "materialDeleted": "Материал удален",
+      "materialSaved": "Материал сохранен",
+      "noConsent": "Нет согласия",
+      "paramsUpdated": "Параметры обновлены",
+      "photoAdded": "Фото добавлено",
+      "photoDeleted": "Фото успешно удалено",
+      "postDeleted": "Запись удалена",
+      "postSaved": "Запись сохранена",
+      "priceUpdated": "Цена обновлена",
+      "roleUpdated": "Роль обновлена",
+      "serviceDeleted": "Услуга удалена",
+      "serviceSaved": "Услуга сохранена",
+      "statusUpdated": "Статус обновлен",
+      "userCreated": "Пользователь создан",
+      "userSaved": "Пользователь сохранен"
+    },
+    "total": "Всего",
+    "filters": "Фильтры",
+    "from": "От",
+    "to": "До",
+    "reset": "Сбросить"
+  }
+}

+ 33 - 157
src/locales/ru.json

@@ -39,163 +39,24 @@
       },
       "title": "Наши ценности",
       "trust": {
-        "content": "Мы доверяем вам оценивать нашу работу.",
+        "content": "Мы приложим все силы, чтобы Вы остались довольны.",
         "title": "Доверие"
       }
     }
   },
-  "admin": {
-    "actions": {
-      "cancel": "Отмена",
-      "create": "Создать",
-      "delete": "Удалить",
-      "deleteFile": "Удалить файл",
-      "edit": "Редактировать",
-      "printInvoice": "Печать счета",
-      "save": "Сохранить",
-      "savePrice": "Сохранить цену",
-      "sending": "Отправка...",
-      "toggleAdminRole": "Переключить роль админа",
-      "viewOriginal": "Оригинал",
-      "saveChanges": "Сохранить изменения",
-      "suspendAccount": "Заблокировать аккаунт",
-      "activateAccount": "Разблокировать аккаунт",
-      "makePublic": "Сделать публичным",
-      "makePrivate": "Сделать приватным",
-      "allowChat": "Разрешить чат",
-      "forbidChat": "Запретить чат"
-    },
-    "addNew": "Добавить",
-    "allStatuses": "Все статусы",
-    "dashboard": "Дэшборд",
-    "fields": {
-      "active": "Активен",
-      "category": "Категория",
-      "colors": "Цвета",
-      "content": "Контент",
-      "customColorInfo": "Введите HEX или название",
-      "defaultColor": "Цвет по умолчанию",
-      "description": "Описание",
-      "email": "Email",
-      "estimated": "Оценка",
-      "excerpt": "Краткое описание",
-      "externalLink": "Внешняя ссылка",
-      "finalPrice": "Финальная цена",
-      "firstName": "Имя",
-      "imageUrl": "URL изображения",
-      "lastName": "Фамилия",
-      "name": "Название",
-      "noPhotos": "Нет фото",
-      "noPortfolio": "Не публиковать в портфолио",
-      "noUsers": "Пользователи не найдены",
-      "notifyUser": "Уведомить клиента",
-      "originalSnapshot": "Снимок заказа",
-      "password": "Пароль",
-      "phone": "Телефон",
-      "photoReport": "Фотоотчет",
-      "portfolioAllowed": "Разрешить в портфолио",
-      "price": "Цена",
-      "pricePerCm3": "Цена за см³",
-      "projectNotes": "Заметки к проекту",
-      "publishImmediately": "Опубликовать сразу",
-      "quantity": "Количество",
-      "selectColorStrict": "Строгий выбор цвета",
-      "selectMaterialStrict": "Строгий выбор материала",
-      "shippingAddress": "Адрес доставки",
-      "slug": "Slug (URL)",
-      "snapshotInfo": "Состояние заказа на момент создания",
-      "sourceFiles": "Исходные файлы",
-      "strictSelectionInfo": "Клиент может выбирать только из списка",
-      "techType": "Тип технологии",
-      "title": "Заголовок",
-      "customColorDirInfo": "Своя цвет (нет инфо в справочнике)",
-      "customColorPlaceholder": "Свой цвет...",
-      "timestamp": "Время",
-      "action": "Действие",
-      "target": "Объект",
-      "details": "Детали",
-      "user": "Пользователь"
-    },
-    "labels": {
-      "actions": "Действия",
-      "chat": "Чат",
-      "contact": "Контакт",
-      "registered": "Зарегистрирован",
-      "role": "Роль",
-      "user": "Пользователь"
-    },
-    "managementCenter": "Центр управления",
-    "modals": {
-      "createMaterial": "Добавить материал",
-      "createPost": "Новая запись",
-      "createService": "Новая услуга",
-      "createUser": "Новый пользователь",
-      "editMaterial": "Редактировать материал",
-      "editPost": "Редактировать запись",
-      "editService": "Редактировать услугу",
-      "editUser": "Редактировать пользователя",
-      "changeParams": "Изменить материал и цвет"
-    },
-    "searchPlaceholder": "Поиск...",
-    "searchUsersPlaceholder": "Поиск пользователей...",
-    "toasts": {
-      "chatDisabled": "Чат отключен",
-      "chatEnabled": "Чат включен",
-      "fileAttached": "Файл прикреплен",
-      "fileDeleted": "Файл удален",
-      "genericError": "Произошла ошибка",
-      "loadError": "Ошибка загрузки",
-      "materialDeleted": "Материал удален",
-      "materialSaved": "Материал сохранен",
-      "noConsent": "Нет согласия",
-      "paramsUpdated": "Параметры обновлены",
-      "photoAdded": "Фото добавлено",
-      "postDeleted": "Запись удалена",
-      "postSaved": "Запись сохранена",
-      "priceUpdated": "Цена обновлена",
-      "roleUpdated": "Роль обновлена",
-      "serviceDeleted": "Услуга удалена",
-      "serviceSaved": "Услуга сохранена",
-      "statusUpdated": "Статус обновлен",
-      "userCreated": "Пользователь создан",
-      "userSaved": "Пользователь сохранен",
-      "photoDeleted": "Фото успешно удалено"
-    },
-    "total": "Всего",
-    "statuses": {
-      "pending": "Ожидание",
-      "processing": "В работе",
-      "shipped": "Отправлено",
-      "completed": "Завершено",
-      "cancelled": "Отменено"
-    },
-    "tabs": {
-      "orders": "Заказы",
-      "materials": "Материалы",
-      "services": "Услуги",
-      "users": "Пользователи",
-      "posts": "Блог",
-      "portfolio": "Портфолио",
-      "blog": "Блог",
-      "audit": "Аудит"
-    },
-    "questions": {
-      "deletePhoto": "Вы уверены, что хотите удалить это фото?"
-    }
-  },
   "auth": {
     "back": "На главную",
     "fields": {
-      "confirmPassword": "Подтвердите пароль",
-      "email": "Email",
-      "newPassword": "Новый пароль",
-      "password": "Пароль",
       "accountType": "Тип аккаунта",
-      "individual": "Частное лицо",
       "company": "Компания",
+      "companyAddress": "Юридический адрес",
       "companyName": "Название компании",
       "companyPIB": "ИНН (PIB)",
-      "companyAddress": "Юридический адрес"
+      "confirmPassword": "Подтвердите пароль",
+      "email": "Email",
+      "individual": "Частное лицо",
+      "newPassword": "Новый пароль",
+      "password": "Пароль"
     },
     "forgot": {
       "link": "Забыли пароль?",
@@ -346,12 +207,12 @@
     "unread": "Новое сообщение"
   },
   "common": {
-    "save_continue": "Сохранить и продолжить",
-    "or": "или",
     "back": "Назад",
-    "pending": "Ожидание...",
     "default": "По умолчанию",
-    "orderId": "Заказ #{id}"
+    "or": "или",
+    "orderId": "Заказ #{id}",
+    "pending": "Ожидание...",
+    "save_continue": "Сохранить и продолжить"
   },
   "contact": {
     "form": {
@@ -407,7 +268,7 @@
     "privacy": "Конфиденциальность",
     "services": "Услуги",
     "support": "Поддержка",
-    "tagline": "Radionica 3D — сервис, построенный на доверии. Мы воплощаем ваши идеи, вы оцениваете наш труд.",
+    "tagline": "Radionica 3D — сервис, построенный на доверии. Мы приложим все силы, чтобы Вы остались довольны.",
     "terms": "Условия"
   },
   "guidelines": {
@@ -580,7 +441,7 @@
     },
     "faq": {
       "q1": {
-        "answer": "Вы сами оцениваете стоимость работы.",
+        "answer": "Мы приложим все силы, чтобы Вы остались довольны.",
         "question": "Сколько стоит печать?"
       },
       "q2": {
@@ -608,7 +469,7 @@
   },
   "hero": {
     "badge": "Доверие в каждом слое",
-    "description": "Уникальный сервис 3D-печати: пришлите модель, получите готовое изделие по почте и заплатите столько, сколько посчитаете нужным.",
+    "description": "Уникальный сервис 3D-печать в Черногории: пришлите модель, получите готовое изделие по почте с оплатой по факту получения.",
     "pricingButton": "Как это работает",
     "stats": {
       "materials": "Материалов",
@@ -619,7 +480,7 @@
       "shippingValue": "Экспресс"
     },
     "title": "Мы печатаем —",
-    "titleGradient": "Вы оцениваете",
+    "titleGradient": "Мы стараемся",
     "uploadButton": "Заказать печать"
   },
   "nav": {
@@ -728,7 +589,7 @@
       "step1": "Отправьте нам STL модель или ссылку",
       "step2": "Мы изготовим ее из подходящего материала",
       "step3": "Получите посылку на указанный адрес",
-      "step4": "Оцените работу и оплатите удобным способом"
+      "step4": "Мы приложим все силы, чтобы Вы остались довольны."
     }
   },
   "privacy": {
@@ -846,7 +707,7 @@
       "title": "Оплата",
       "trustModel": {
         "point1": "Оплата после доставки.",
-        "point2": "Вы оцениваете качество.",
+        "point2": "Мы гарантируем качество.",
         "point3": "Честное использование.",
         "point4": "Поддержка включена.",
         "title": "Детали"
@@ -925,9 +786,24 @@
       "noCommissions": "Без комиссий",
       "noPrepayment": "Bez предоплаты",
       "shipping": "Отправка почтой",
-      "yourPrice": "Ваша цена"
+      "yourPrice": "Честная цена"
     },
     "title": "Почему мы",
     "titleItalic": "доверяем"
+  },
+  "statuses": {
+    "pending": "В ожидании",
+    "processing": "В работе",
+    "shipped": "Отправлено",
+    "completed": "Завершено",
+    "cancelled": "Отменено",
+    "approved": "Одобрено",
+    "printing": "Печать",
+    "delivered": "Доставлено"
+  },
+  "admin": {
+    "actions": {
+      "deleteOrder": "Удалить заказ полностью"
+    }
   }
 }

+ 756 - 0
src/locales/translations.admin.json

@@ -0,0 +1,756 @@
+{
+  "admin": {
+    "actions": {
+      "activateAccount": {
+        "en": "Activate account",
+        "me": "Aktiviraj nalog",
+        "ru": "Разблокировать аккаунт",
+        "ua": "Розблокувати акаунт"
+      },
+      "allowChat": {
+        "en": "Allow Chat",
+        "me": "Dozvoli čet",
+        "ru": "Разрешить чат",
+        "ua": "Дозволити чат"
+      },
+      "cancel": {
+        "en": "Cancel",
+        "me": "Otkaži",
+        "ru": "Отмена",
+        "ua": "Скасувати"
+      },
+      "create": {
+        "en": "Create",
+        "me": "Kreiraj",
+        "ru": "Создать",
+        "ua": "Створити"
+      },
+      "delete": {
+        "en": "Delete",
+        "me": "Obriši",
+        "ru": "Удалить",
+        "ua": "Видалити"
+      },
+      "deleteFile": {
+        "en": "Delete attached file",
+        "me": "Obriši zakačeni fajl",
+        "ru": "Удалить файл",
+        "ua": "Видалити файл"
+      },
+      "edit": {
+        "en": "Edit",
+        "me": "Uredi",
+        "ru": "Редактировать",
+        "ua": "Редагувати"
+      },
+      "forbidChat": {
+        "en": "Forbid Chat",
+        "me": "Zabrani čet",
+        "ru": "Запретить чат",
+        "ua": "Заборонити чат"
+      },
+      "makePrivate": {
+        "en": "Make Private",
+        "me": "Učini privatnim",
+        "ru": "Сделать приватным",
+        "ua": "Зробити приватним"
+      },
+      "makePublic": {
+        "en": "Make Public",
+        "me": "Učini javnim",
+        "ru": "Сделать публичным",
+        "ua": "Зробити публічним"
+      },
+      "printInvoice": {
+        "en": "Print Final Invoice (Faktura)",
+        "me": "Odštampaj fakturu",
+        "ru": "Печать фактуры",
+        "ua": "Друк фактури"
+      },
+      "printProforma": {
+        "en": "Print Proforma (Predračun/Uplatnica)",
+        "me": "Odštampaj predračun",
+        "ru": "Печать счета на оплату",
+        "ua": "Друк рахунку на оплату"
+      },
+      "save": {
+        "en": "Save Changes",
+        "me": "Sačuvaj izmjene",
+        "ru": "Сохранить",
+        "ua": "Зберегти"
+      },
+      "saveChanges": {
+        "en": "Save Changes",
+        "me": "Sačuvaj promjene",
+        "ru": "Сохранить изменения",
+        "ua": "Зберегти зміни"
+      },
+      "savePrice": {
+        "en": "Save Price",
+        "me": "Sačuvaj cijenu",
+        "ru": "Сохранить цену",
+        "ua": "Зберегти ціну"
+      },
+      "sending": {
+        "en": "Sending...",
+        "me": "Slanje...",
+        "ru": "Отправка...",
+        "ua": "Надсилання..."
+      },
+      "suspendAccount": {
+        "en": "Suspend account",
+        "me": "Suspenduj nalog",
+        "ru": "Заблокировать аккаунт",
+        "ua": "Заблокувати акаунт"
+      },
+      "toggleAdminRole": {
+        "en": "Toggle Admin role",
+        "me": "Promijeni admin ulogu",
+        "ru": "Переключить роль админа",
+        "ua": "Перемкнути роль адміна"
+      },
+      "viewOriginal": {
+        "en": "View Original Snapshot",
+        "me": "Vidi originalne parametre",
+        "ru": "Оригинал",
+        "ua": "Оригінал"
+      }
+    },
+    "addNew": {
+      "en": "Add New",
+      "me": "Dodaj novo",
+      "ru": "Добавить",
+      "ua": "Додати"
+    },
+    "allStatuses": {
+      "en": "All Statuses",
+      "me": "Svi statusi",
+      "ru": "Все статусы",
+      "ua": "Усі статуси"
+    },
+    "dashboard": {
+      "en": "Dashboard",
+      "me": "Panel",
+      "ru": "Дэшборд",
+      "ua": "Дешборд"
+    },
+    "fields": {
+      "action": {
+        "en": "Action",
+        "me": "Akcija",
+        "ru": "Действие",
+        "ua": "Дія"
+      },
+      "active": {
+        "en": "Active and Visible",
+        "me": "Aktivan i vidljiv",
+        "ru": "Активен",
+        "ua": "Активний"
+      },
+      "category": {
+        "en": "Category",
+        "me": "Kategorija",
+        "ru": "Категория",
+        "ua": "Категорія"
+      },
+      "colors": {
+        "en": "Available Colors",
+        "me": "Dostupne boje",
+        "ru": "Цвета",
+        "ua": "Кольори"
+      },
+      "content": {
+        "en": "Content",
+        "me": "Sadržaj",
+        "ru": "Контент",
+        "ua": "Контент"
+      },
+      "customColorDirInfo": {
+        "en": "Custom Color (No directory info)",
+        "me": "Prilagođena boja (nema info u direktorijumu)",
+        "ru": "Своя цвет (нет инфо в справочнике)",
+        "ua": "Свій колір (немає інфо в довіднику)"
+      },
+      "customColorInfo": {
+        "en": "Custom Color (No directory info)",
+        "me": "Prilagođena boja (bez informacija iz kataloga)",
+        "ru": "Введите HEX или название",
+        "ua": "Введіть HEX або назву"
+      },
+      "customColorPlaceholder": {
+        "en": "Custom color...",
+        "me": "Prilagođena boja...",
+        "ru": "Свой цвет...",
+        "ua": "Свій колір..."
+      },
+      "defaultColor": {
+        "en": "Default Color",
+        "me": "Podrazumijevana boja",
+        "ru": "Цвет по умолчанию",
+        "ua": "Колір за замовчуванням"
+      },
+      "description": {
+        "en": "Description",
+        "me": "Opis",
+        "ru": "Описание",
+        "ua": "Опис"
+      },
+      "details": {
+        "en": "Details",
+        "me": "Detalji",
+        "ru": "Детали",
+        "ua": "Деталі"
+      },
+      "email": {
+        "en": "Email Address",
+        "me": "Email adresa",
+        "ru": "Email",
+        "ua": "Email"
+      },
+      "estimated": {
+        "en": "Estimated",
+        "me": "Procjena",
+        "ru": "Оценка",
+        "ua": "Оцінка"
+      },
+      "excerpt": {
+        "en": "Excerpt",
+        "me": "Kratak opis",
+        "ru": "Краткое описание",
+        "ua": "Короткий опис"
+      },
+      "externalLink": {
+        "en": "External Model Link",
+        "me": "Link ka modelu",
+        "ru": "Внешняя ссылка",
+        "ua": "Зовнішнє посилання"
+      },
+      "finalPrice": {
+        "en": "Final Price",
+        "me": "Konačna cijena",
+        "ru": "Финальная цена",
+        "ua": "Фінальна ціна"
+      },
+      "firstName": {
+        "en": "First Name",
+        "me": "Ime",
+        "ru": "Имя",
+        "ua": "Ім'я"
+      },
+      "imageUrl": {
+        "en": "Image URL",
+        "me": "URL slike",
+        "ru": "URL изображения",
+        "ua": "URL зображення"
+      },
+      "lastName": {
+        "en": "Last Name",
+        "me": "Prezime",
+        "ru": "Фамилия",
+        "ua": "Прізвище"
+      },
+      "name": {
+        "en": "Name",
+        "me": "Naziv",
+        "ru": "Название",
+        "ua": "Назва"
+      },
+      "noPhotos": {
+        "en": "No photos yet",
+        "me": "Nema fotografija",
+        "ru": "Нет фото",
+        "ua": "Немає фото"
+      },
+      "noPortfolio": {
+        "en": "Do not publish in portfolio",
+        "me": "Ne objavljivati u portfoliju",
+        "ru": "Не публиковать в портфолио",
+        "ua": "Не публікувати в портфоліо"
+      },
+      "noUsers": {
+        "en": "No users found",
+        "me": "Korisnici nisu pronađeni",
+        "ru": "Пользователи не найдены",
+        "ua": "Користувачів не знайдено"
+      },
+      "notifyUser": {
+        "en": "Notify User",
+        "me": "Obavijesti korisnika",
+        "ru": "Уведомить клиента",
+        "ua": "Повідомити клієнта"
+      },
+      "originalSnapshot": {
+        "en": "Original Snapshot",
+        "me": "Originalni parametri",
+        "ru": "Снимок заказа",
+        "ua": "Знімок замовлення"
+      },
+      "password": {
+        "en": "Password",
+        "me": "Lozinka",
+        "ru": "Пароль",
+        "ua": "Пароль"
+      },
+      "phone": {
+        "en": "Phone Number",
+        "me": "Broj telefona",
+        "ru": "Телефон",
+        "ua": "Телефон"
+      },
+      "photoReport": {
+        "en": "Photo Report",
+        "me": "Foto izvještaj",
+        "ru": "Фотоотчет",
+        "ua": "Фотозвіт"
+      },
+      "portfolioAllowed": {
+        "en": "Portfolio Allowed",
+        "me": "Portfolio dozvoljen",
+        "ru": "Разрешить в портфолио",
+        "ua": "Дозволити в портфоліо"
+      },
+      "price": {
+        "en": "Price per cm³",
+        "me": "Cijena po cm³",
+        "ru": "Цена",
+        "ua": "Ціна"
+      },
+      "pricePerCm3": {
+        "en": "Price / cm³",
+        "me": "Cijena / cm³",
+        "ru": "Цена за см³",
+        "ua": "Ціна за см³"
+      },
+      "projectNotes": {
+        "en": "Project Notes",
+        "me": "Napomene o projektu",
+        "ru": "Заметки к проекту",
+        "ua": "Нотатки до проєкту"
+      },
+      "publishImmediately": {
+        "en": "Publish immediately",
+        "me": "Objavi odmah",
+        "ru": "Опубликовать сразу",
+        "ua": "Опублікувати відразу"
+      },
+      "quantity": {
+        "en": "Quantity",
+        "me": "Količina",
+        "ru": "Количество",
+        "ua": "Кількість"
+      },
+      "selectColorStrict": {
+        "en": "Select Color (Strict)",
+        "me": "Izaberi boju",
+        "ru": "Строгий выбор цвета",
+        "ua": "Суворий вибір кольору"
+      },
+      "selectMaterialStrict": {
+        "en": "Select Material (Strict)",
+        "me": "Izaberi materijal",
+        "ru": "Строгий выбор материала",
+        "ua": "Суворий вибір материала"
+      },
+      "shippingAddress": {
+        "en": "Shipping Address",
+        "me": "Adresa za dostavu",
+        "ru": "Адрес доставки",
+        "ua": "Адреса доставки"
+      },
+      "slug": {
+        "en": "Slug (URL)",
+        "me": "Slug (URL)",
+        "ru": "Slug (URL)",
+        "ua": "Slug (URL)"
+      },
+      "snapshotInfo": {
+        "en": "These are the parameters recorded at the moment of order submission.",
+        "me": "Ovo su parametri zabilježeni u trenutku slanja narudžbe.",
+        "ru": "Состояние заказа на момент создания",
+        "ua": "Стан замовлення на момент створення"
+      },
+      "sourceFiles": {
+        "en": "Source Files",
+        "me": "Izvorni fajlovi",
+        "ru": "Исходные файлы",
+        "ua": "Вихідні файли"
+      },
+      "strictSelectionInfo": {
+        "en": "Prices and options are derived strictly from the catalog.",
+        "me": "Cijene i opcije se izvode strogo iz kataloga.",
+        "ru": "Цены и опции берутся строго из каталога.",
+        "ua": "Ціни та опції беруться суворо з каталогу."
+      },
+      "target": {
+        "en": "Target",
+        "me": "Cilj",
+        "ru": "Объект",
+        "ua": "Об'єкт"
+      },
+      "techType": {
+        "en": "Technology Type",
+        "me": "Tip tehnologije",
+        "ru": "Тип технологии",
+        "ua": "Тип технології"
+      },
+      "timestamp": {
+        "en": "Timestamp",
+        "me": "Vrijeme",
+        "ru": "Время",
+        "ua": "Час"
+      },
+      "title": {
+        "en": "Title",
+        "me": "Naslov",
+        "ru": "Заголовок",
+        "ua": "Заголовок"
+      },
+      "updateFinalPrice": {
+        "en": "Update Final Price",
+        "me": "Ažuriraj konačnu cijenu",
+        "ru": "Обновить итоговую цену",
+        "ua": "Оновити підсумкову ціну"
+      },
+      "user": {
+        "en": "User",
+        "me": "Korisnik",
+        "ru": "Пользователь",
+        "ua": "Користувач"
+      }
+    },
+    "labels": {
+      "actions": {
+        "en": "Actions",
+        "me": "Akcije",
+        "ru": "Действия",
+        "ua": "Дії"
+      },
+      "chat": {
+        "en": "Chat",
+        "me": "Čat",
+        "ru": "Чат",
+        "ua": "Чат"
+      },
+      "contact": {
+        "en": "Contact",
+        "me": "Kontakt",
+        "ru": "Контакт",
+        "ua": "Контакт"
+      },
+      "registered": {
+        "en": "Registered",
+        "me": "Registrovan",
+        "ru": "Зарегистрирован",
+        "ua": "Зареєстрований"
+      },
+      "role": {
+        "en": "Role",
+        "me": "Uloga",
+        "ru": "Роль",
+        "ua": "Роль"
+      },
+      "user": {
+        "en": "User",
+        "me": "Korisnik",
+        "ru": "Пользователь",
+        "ua": "Користувач"
+      }
+    },
+    "managementCenter": {
+      "en": "Management Center",
+      "me": "Centar za upravljanje",
+      "ru": "Центр управления",
+      "ua": "Центр управління"
+    },
+    "modals": {
+      "changeParams": {
+        "en": "Change Material & Color",
+        "me": "Promijeni materijal i boju",
+        "ru": "Изменить материал и цвет",
+        "ua": "Змінити матеріал та колір"
+      },
+      "createMaterial": {
+        "en": "Create New Material",
+        "me": "Novi materijal",
+        "ru": "Добавить материал",
+        "ua": "Додати матеріал"
+      },
+      "createPost": {
+        "en": "Create New Post",
+        "me": "Novi članak",
+        "ru": "Новая запись",
+        "ua": "Новий запис"
+      },
+      "createService": {
+        "en": "Create New Service",
+        "me": "Nova usluga",
+        "ru": "Новая услуга",
+        "ua": "Нова послуга"
+      },
+      "createUser": {
+        "en": "Create New User",
+        "me": "Novi korisnik",
+        "ru": "Новый пользователь",
+        "ua": "Новий користувач"
+      },
+      "editMaterial": {
+        "en": "Edit Material",
+        "me": "Uredi materijal",
+        "ru": "Редактировать материал",
+        "ua": "Редагувати матеріал"
+      },
+      "editPost": {
+        "en": "Edit Blog Post",
+        "me": "Uredi članak",
+        "ru": "Редактировать запись",
+        "ua": "Редагувати запис"
+      },
+      "editService": {
+        "en": "Edit Service",
+        "me": "Uredi uslugu",
+        "ru": "Редактировать услугу",
+        "ua": "Редагувати послугу"
+      },
+      "editUser": {
+        "en": "Edit User",
+        "me": "Uredi korisnika",
+        "ru": "Редактировать пользователя",
+        "ua": "Редагувати користувача"
+      }
+    },
+    "questions": {
+      "deletePhoto": {
+        "en": "Are you sure you want to delete this photo?",
+        "me": "Da li ste sigurni da želite obrisati ovu fotografiju?",
+        "ru": "Вы уверены, что хотите удалить это фото?",
+        "ua": "Ви впевнені, що хочете видалити це фото?"
+      }
+    },
+    "searchPlaceholder": {
+      "en": "Search {tab}...",
+      "me": "Traži {tab}...",
+      "ru": "Поиск...",
+      "ua": "Пошук..."
+    },
+    "searchUsersPlaceholder": {
+      "en": "Search by name, email, phone...",
+      "me": "Traži po imenu, emailu, telefonu...",
+      "ru": "Поиск пользователей...",
+      "ua": "Пошук користувачів..."
+    },
+    "tabs": {
+      "audit": {
+        "en": "Audit",
+        "me": "Audit",
+        "ru": "Аудит",
+        "ua": "Аудит"
+      },
+      "blog": {
+        "en": "Blog",
+        "me": "Blog",
+        "ru": "Блог",
+        "ua": "Блог"
+      },
+      "materials": {
+        "en": "Materials",
+        "me": "Materijali",
+        "ru": "Материалы",
+        "ua": "Матеріали"
+      },
+      "orders": {
+        "en": "Orders",
+        "me": "Narudžbe",
+        "ru": "Заказы",
+        "ua": "Замовлення"
+      },
+      "portfolio": {
+        "en": "Portfolio",
+        "me": "Portfolio",
+        "ru": "Портфолио",
+        "ua": "Портфоліо"
+      },
+      "posts": {
+        "en": "Blog",
+        "me": "Blog",
+        "ru": "Блог",
+        "ua": "Блог"
+      },
+      "services": {
+        "en": "Services",
+        "me": "Usluge",
+        "ru": "Услуги",
+        "ua": "Послуги"
+      },
+      "users": {
+        "en": "Users",
+        "me": "Korisnici",
+        "ru": "Пользователи",
+        "ua": "Користувачі"
+      }
+    },
+    "toasts": {
+      "chatDisabled": {
+        "en": "Chat for User #{id} is now Disabled",
+        "me": "Čat za korisnika #{id} je ONEMOGUĆEN",
+        "ru": "Чат отключен",
+        "ua": "Чат вимкнено"
+      },
+      "chatEnabled": {
+        "en": "Chat for User #{id} is now Enabled",
+        "me": "Čat za korisnika #{id} je OMOGUĆEN",
+        "ru": "Чат включен",
+        "ua": "Чат увімкнено"
+      },
+      "fileAttached": {
+        "en": "File attached and preview generated",
+        "me": "Fajl okačen i pregled generisan",
+        "ru": "Файл прикреплен",
+        "ua": "Файл прикріплено"
+      },
+      "fileDeleted": {
+        "en": "File deleted successfully",
+        "me": "Fajl uspješno obrisan",
+        "ru": "Файл удален",
+        "ua": "Файл видалено"
+      },
+      "genericError": {
+        "en": "Operation failed",
+        "me": "Operacija nije uspjela",
+        "ru": "Произошла ошибка",
+        "ua": "Сталася помилка"
+      },
+      "invoiceReminder": {
+        "en": "Invoice generated. Don't forget to print and attach it to the package!",
+        "me": "Faktura je generisana. Ne zaboravite da je odštampate i priložite uz paket!",
+        "ru": "Фактура создана. Не забудьте распечатать и приложить её к посылке!",
+        "ua": "Фактура створена. Не забудьте роздрукувати та додати її до посилки!"
+      },
+      "loadError": {
+        "en": "Failed to load {tab}",
+        "me": "Greška pri učitavanju {tab}",
+        "ru": "Ошибка загрузки",
+        "ua": "Помилка завантаження"
+      },
+      "materialDeleted": {
+        "en": "Material deleted",
+        "me": "Materijal obrisan",
+        "ru": "Материал удален",
+        "ua": "Матеріал видалено"
+      },
+      "materialSaved": {
+        "en": "Material saved",
+        "me": "Materijal sačuvan",
+        "ru": "Материал сохранен",
+        "ua": "Матеріал збережено"
+      },
+      "noConsent": {
+        "en": "User did not consent to portfolio",
+        "me": "Korisnik nije dao saglasnost za portfolio",
+        "ru": "Нет согласия",
+        "ua": "Немає згоди"
+      },
+      "paramsUpdated": {
+        "en": "Parameters updated",
+        "me": "Parametri ažurirani",
+        "ru": "Параметры обновлены",
+        "ua": "Параметри оновлено"
+      },
+      "photoAdded": {
+        "en": "Photo added",
+        "me": "Fotografija dodata",
+        "ru": "Фото добавлено",
+        "ua": "Фото додано"
+      },
+      "photoDeleted": {
+        "en": "Photo deleted successfully",
+        "me": "Fotografija uspješno obrisana",
+        "ru": "Фото успешно удалено",
+        "ua": "Фото успішно видалено"
+      },
+      "postDeleted": {
+        "en": "Article deleted",
+        "me": "Članak obrisan",
+        "ru": "Запись удалена",
+        "ua": "Запис видалено"
+      },
+      "postSaved": {
+        "en": "Article saved",
+        "me": "Članak sačuvan",
+        "ru": "Запись сохранена",
+        "ua": "Запис збережено"
+      },
+      "priceUpdated": {
+        "en": "Price updated",
+        "me": "Cijena ažurirana",
+        "ru": "Цена обновлена",
+        "ua": "Ціна оновлена"
+      },
+      "roleUpdated": {
+        "en": "Role for User #{id} updated to {role}",
+        "me": "Uloga korisnika #{id} promijenjena u {role}",
+        "ru": "Роль обновлена",
+        "ua": "Роль оновлена"
+      },
+      "serviceDeleted": {
+        "en": "Service deleted",
+        "me": "Usluga obrisana",
+        "ru": "Услуга удалена",
+        "ua": "Послуга видалена"
+      },
+      "serviceSaved": {
+        "en": "Service saved",
+        "me": "Usluga sačuvana",
+        "ru": "Услуга сохранена",
+        "ua": "Послугу збережено"
+      },
+      "statusUpdated": {
+        "en": "Status → {status}",
+        "me": "Status → {status}",
+        "ru": "Статус обновлен",
+        "ua": "Статус оновлено"
+      },
+      "userCreated": {
+        "en": "User created successfully",
+        "me": "Korisnik uspješno kreiran",
+        "ru": "Пользователь создан",
+        "ua": "Користувач створений"
+      },
+      "userSaved": {
+        "en": "User saved",
+        "me": "Korisnik sačuvan",
+        "ru": "Пользователь сохранен",
+        "ua": "Користувача збережено"
+      }
+    },
+    "total": {
+      "en": "Total",
+      "me": "Ukupno",
+      "ru": "Всего",
+      "ua": "Всього"
+    },
+    "filters": {
+      "en": "Filters",
+      "me": "Filteri",
+      "ru": "Фильтры",
+      "ua": "Фільтри"
+    },
+    "from": {
+      "en": "From",
+      "me": "Od",
+      "ru": "От",
+      "ua": "Від"
+    },
+    "to": {
+      "en": "To",
+      "me": "Do",
+      "ru": "До",
+      "ua": "До"
+    },
+    "reset": {
+      "en": "Reset",
+      "me": "Resetuj",
+      "ru": "Сбросить",
+      "ua": "Скинути"
+    }
+  }
+}

Diff do ficheiro suprimidas por serem muito extensas
+ 78 - 822
src/locales/translations.user.json


+ 141 - 0
src/locales/ua.admin.json

@@ -0,0 +1,141 @@
+{
+  "admin": {
+    "actions": {
+      "activateAccount": "Розблокувати акаунт",
+      "allowChat": "Дозволити чат",
+      "cancel": "Скасувати",
+      "create": "Створити",
+      "delete": "Видалити",
+      "deleteFile": "Видалити файл",
+      "edit": "Редагувати",
+      "forbidChat": "Заборонити чат",
+      "makePrivate": "Зробити приватним",
+      "makePublic": "Зробити публічним",
+      "printInvoice": "Друк фактури",
+      "printProforma": "Друк рахунку на оплату",
+      "save": "Зберегти",
+      "saveChanges": "Зберегти зміни",
+      "savePrice": "Зберегти ціну",
+      "sending": "Надсилання...",
+      "suspendAccount": "Заблокувати акаунт",
+      "toggleAdminRole": "Перемкнути роль адміна",
+      "viewOriginal": "Оригінал"
+    },
+    "addNew": "Додати",
+    "allStatuses": "Усі статуси",
+    "dashboard": "Дешборд",
+    "fields": {
+      "action": "Дія",
+      "active": "Активний",
+      "category": "Категорія",
+      "colors": "Кольори",
+      "content": "Контент",
+      "customColorDirInfo": "Свій колір (немає інфо в довіднику)",
+      "customColorInfo": "Введіть HEX або назву",
+      "customColorPlaceholder": "Свій колір...",
+      "defaultColor": "Колір за замовчуванням",
+      "description": "Опис",
+      "details": "Деталі",
+      "email": "Email",
+      "estimated": "Оцінка",
+      "excerpt": "Короткий опис",
+      "externalLink": "Зовнішнє посилання",
+      "finalPrice": "Фінальна ціна",
+      "firstName": "Ім'я",
+      "imageUrl": "URL зображення",
+      "lastName": "Прізвище",
+      "name": "Назва",
+      "noPhotos": "Немає фото",
+      "noPortfolio": "Не публікувати в портфоліо",
+      "noUsers": "Користувачів не знайдено",
+      "notifyUser": "Повідомити клієнта",
+      "originalSnapshot": "Знімок замовлення",
+      "password": "Пароль",
+      "phone": "Телефон",
+      "photoReport": "Фотозвіт",
+      "portfolioAllowed": "Дозволити в портфоліо",
+      "price": "Ціна",
+      "pricePerCm3": "Ціна за см³",
+      "projectNotes": "Нотатки до проєкту",
+      "publishImmediately": "Опублікувати відразу",
+      "quantity": "Кількість",
+      "selectColorStrict": "Суворий вибір кольору",
+      "selectMaterialStrict": "Суворий вибір материала",
+      "shippingAddress": "Адреса доставки",
+      "slug": "Slug (URL)",
+      "snapshotInfo": "Стан замовлення на момент створення",
+      "sourceFiles": "Вихідні файли",
+      "strictSelectionInfo": "Ціни та опції беруться суворо з каталогу.",
+      "target": "Об'єкт",
+      "techType": "Тип технології",
+      "timestamp": "Час",
+      "title": "Заголовок",
+      "updateFinalPrice": "Оновити підсумкову ціну",
+      "user": "Користувач"
+    },
+    "labels": {
+      "actions": "Дії",
+      "chat": "Чат",
+      "contact": "Контакт",
+      "registered": "Зареєстрований",
+      "role": "Роль",
+      "user": "Користувач"
+    },
+    "managementCenter": "Центр управління",
+    "modals": {
+      "changeParams": "Змінити матеріал та колір",
+      "createMaterial": "Додати матеріал",
+      "createPost": "Новий запис",
+      "createService": "Нова послуга",
+      "createUser": "Новий користувач",
+      "editMaterial": "Редагувати матеріал",
+      "editPost": "Редагувати запис",
+      "editService": "Редагувати послугу",
+      "editUser": "Редагувати користувача"
+    },
+    "questions": {
+      "deletePhoto": "Ви впевнені, що хочете видалити це фото?"
+    },
+    "searchPlaceholder": "Пошук...",
+    "searchUsersPlaceholder": "Пошук користувачів...",
+    "tabs": {
+      "audit": "Аудит",
+      "blog": "Блог",
+      "materials": "Матеріали",
+      "orders": "Замовлення",
+      "portfolio": "Портфоліо",
+      "posts": "Блог",
+      "services": "Послуги",
+      "users": "Користувачі"
+    },
+    "toasts": {
+      "chatDisabled": "Чат вимкнено",
+      "chatEnabled": "Чат увімкнено",
+      "fileAttached": "Файл прикріплено",
+      "fileDeleted": "Файл видалено",
+      "genericError": "Сталася помилка",
+      "invoiceReminder": "Фактура створена. Не забудьте роздрукувати та додати її до посилки!",
+      "loadError": "Помилка завантаження",
+      "materialDeleted": "Матеріал видалено",
+      "materialSaved": "Матеріал збережено",
+      "noConsent": "Немає згоди",
+      "paramsUpdated": "Параметри оновлено",
+      "photoAdded": "Фото додано",
+      "photoDeleted": "Фото успішно видалено",
+      "postDeleted": "Запис видалено",
+      "postSaved": "Запис збережено",
+      "priceUpdated": "Ціна оновлена",
+      "roleUpdated": "Роль оновлена",
+      "serviceDeleted": "Послуга видалена",
+      "serviceSaved": "Послугу збережено",
+      "statusUpdated": "Статус оновлено",
+      "userCreated": "Користувач створений",
+      "userSaved": "Користувача збережено"
+    },
+    "total": "Всього",
+    "filters": "Фільтри",
+    "from": "Від",
+    "to": "До",
+    "reset": "Скинути"
+  }
+}

+ 34 - 158
src/locales/ua.json

@@ -39,163 +39,24 @@
       },
       "title": "Наші цінності",
       "trust": {
-        "content": "Ми довіряємо вам оцінювати нашу роботу.",
+        "content": "Ми докладемо всіх зусиль, щоб Ви залишилися задоволені.",
         "title": "Довіра"
       }
     }
   },
-  "admin": {
-    "actions": {
-      "cancel": "Скасувати",
-      "create": "Створити",
-      "delete": "Видалити",
-      "deleteFile": "Видалити файл",
-      "edit": "Редагувати",
-      "printInvoice": "Друк рахунку",
-      "save": "Зберегти",
-      "savePrice": "Зберегти ціну",
-      "sending": "Надсилання...",
-      "toggleAdminRole": "Перемкнути роль адміна",
-      "viewOriginal": "Оригінал",
-      "saveChanges": "Зберегти зміни",
-      "suspendAccount": "Заблокувати акаунт",
-      "activateAccount": "Розблокувати акаунт",
-      "makePublic": "Зробити публічним",
-      "makePrivate": "Зробити приватним",
-      "allowChat": "Дозволити чат",
-      "forbidChat": "Заборонити чат"
-    },
-    "addNew": "Додати",
-    "allStatuses": "Усі статуси",
-    "dashboard": "Дешборд",
-    "fields": {
-      "active": "Активний",
-      "category": "Категорія",
-      "colors": "Кольори",
-      "content": "Контент",
-      "customColorInfo": "Введіть HEX або назву",
-      "defaultColor": "Колір за замовчуванням",
-      "description": "Опис",
-      "email": "Email",
-      "estimated": "Оцінка",
-      "excerpt": "Короткий опис",
-      "externalLink": "Зовнішнє посилання",
-      "finalPrice": "Фінальна ціна",
-      "firstName": "Ім'я",
-      "imageUrl": "URL зображення",
-      "lastName": "Прізвище",
-      "name": "Назва",
-      "noPhotos": "Немає фото",
-      "noPortfolio": "Не публікувати в портфоліо",
-      "noUsers": "Користувачів не знайдено",
-      "notifyUser": "Повідомити клієнта",
-      "originalSnapshot": "Знімок замовлення",
-      "password": "Пароль",
-      "phone": "Телефон",
-      "photoReport": "Фотозвіт",
-      "portfolioAllowed": "Дозволити в портфоліо",
-      "price": "Ціна",
-      "pricePerCm3": "Ціна за см³",
-      "projectNotes": "Нотатки до проєкту",
-      "publishImmediately": "Опублікувати відразу",
-      "quantity": "Кількість",
-      "selectColorStrict": "Суворий вибір кольору",
-      "selectMaterialStrict": "Суворий вибір матеріалу",
-      "shippingAddress": "Адреса доставки",
-      "slug": "Slug (URL)",
-      "snapshotInfo": "Стан замовлення на момент створення",
-      "sourceFiles": "Вихідні файли",
-      "strictSelectionInfo": "Клієнт може вибирати тільки зі списку",
-      "techType": "Тип технології",
-      "title": "Заголовок",
-      "customColorDirInfo": "Свій колір (немає інфо в довіднику)",
-      "customColorPlaceholder": "Свій колір...",
-      "timestamp": "Час",
-      "action": "Дія",
-      "target": "Об'єкт",
-      "details": "Деталі",
-      "user": "Користувач"
-    },
-    "labels": {
-      "actions": "Дії",
-      "chat": "Чат",
-      "contact": "Контакт",
-      "registered": "Зареєстрований",
-      "role": "Роль",
-      "user": "Користувач"
-    },
-    "managementCenter": "Центр управління",
-    "modals": {
-      "createMaterial": "Додати матеріал",
-      "createPost": "Новий запис",
-      "createService": "Нова послуга",
-      "createUser": "Новий користувач",
-      "editMaterial": "Редагувати матеріал",
-      "editPost": "Редагувати запис",
-      "editService": "Редагувати послугу",
-      "editUser": "Редагувати користувача",
-      "changeParams": "Змінити матеріал та колір"
-    },
-    "searchPlaceholder": "Пошук...",
-    "searchUsersPlaceholder": "Пошук користувачів...",
-    "toasts": {
-      "chatDisabled": "Чат вимкнено",
-      "chatEnabled": "Чат увімкнено",
-      "fileAttached": "Файл прикріплено",
-      "fileDeleted": "Файл видалено",
-      "genericError": "Сталася помилка",
-      "loadError": "Помилка завантаження",
-      "materialDeleted": "Матеріал видалено",
-      "materialSaved": "Матеріал збережено",
-      "noConsent": "Немає згоди",
-      "paramsUpdated": "Параметри оновлено",
-      "photoAdded": "Фото додано",
-      "postDeleted": "Запис видалено",
-      "postSaved": "Запис збережено",
-      "priceUpdated": "Ціна оновлена",
-      "roleUpdated": "Роль оновлена",
-      "serviceDeleted": "Послуга видалена",
-      "serviceSaved": "Послугу збережено",
-      "statusUpdated": "Статус оновлено",
-      "userCreated": "Користувач створений",
-      "userSaved": "Користувача збережено",
-      "photoDeleted": "Фото успішно видалено"
-    },
-    "total": "Всього",
-    "statuses": {
-      "pending": "Очікування",
-      "processing": "В роботі",
-      "shipped": "Відправлено",
-      "completed": "Завершено",
-      "cancelled": "Скасовано"
-    },
-    "tabs": {
-      "orders": "Замовлення",
-      "materials": "Матеріали",
-      "services": "Послуги",
-      "users": "Користувачі",
-      "posts": "Блог",
-      "portfolio": "Портфоліо",
-      "blog": "Блог",
-      "audit": "Аудит"
-    },
-    "questions": {
-      "deletePhoto": "Ви впевнені, що хочете видалити це фото?"
-    }
-  },
   "auth": {
     "back": "На головну",
     "fields": {
-      "confirmPassword": "Підтвердьте пароль",
-      "email": "Email",
-      "newPassword": "Новий пароль",
-      "password": "Пароль",
       "accountType": "Тип акаунту",
-      "individual": "Приватна особа",
       "company": "Компанія",
+      "companyAddress": "Юридична адреса",
       "companyName": "Назва компанії",
       "companyPIB": "ІПН (PIB)",
-      "companyAddress": "Юридична адреса"
+      "confirmPassword": "Підтвердьте пароль",
+      "email": "Email",
+      "individual": "Приватна особа",
+      "newPassword": "Новий пароль",
+      "password": "Пароль"
     },
     "forgot": {
       "link": "Забули свій пароль?",
@@ -346,12 +207,12 @@
     "unread": "Нове повідомлення"
   },
   "common": {
-    "save_continue": "Зберегти та продовжити",
-    "or": "або",
     "back": "Назад",
-    "pending": "Очікування...",
     "default": "За замовчуванням",
-    "orderId": "Замовлення #{id}"
+    "or": "або",
+    "orderId": "Замовлення #{id}",
+    "pending": "Очікування...",
+    "save_continue": "Зберегти та продовжити"
   },
   "contact": {
     "form": {
@@ -407,7 +268,7 @@
     "privacy": "Конфіденційність",
     "services": "Послуги",
     "support": "Підтримка",
-    "tagline": "Radionica 3D - сервіс, побудований на довірі. Ми втілюємо ваші ідеї, ви оцінюєте нашу працю.",
+    "tagline": "Radionica 3D — сервіс, побудований на довірі. Ми докладемо всіх зусиль, щоб Ви залишилися задоволені.",
     "terms": "Умови"
   },
   "guidelines": {
@@ -580,7 +441,7 @@
     },
     "faq": {
       "q1": {
-        "answer": "Ви самі оцінюєте вартість роботи.",
+        "answer": "Ми докладемо всіх зусиль, щоб Ви залишилися задоволені.",
         "question": "Скільки коштує друк?"
       },
       "q2": {
@@ -607,8 +468,8 @@
     "subtitle": "Як ми можемо допомогти?"
   },
   "hero": {
-    "badge": "Довіра у кожному шарі",
-    "description": "Унікальний сервіс 3D-друку: надішліть модель, отримайте готовий виріб поштою та заплатіть стільки, скільки вважаєте за потрібне.",
+    "badge": "Якість у кожному шарі",
+    "description": "Унікальний сервіс 3D-друку в Чорногорії: надішліть модель, отримайте готовий виріб поштою з оплатою після отримання.",
     "pricingButton": "Як це працює",
     "stats": {
       "materials": "матеріалів",
@@ -619,7 +480,7 @@
       "shippingValue": "Експрес"
     },
     "title": "Ми друкуємо",
-    "titleGradient": "Ви оцінюєте",
+    "titleGradient": "Ми дбаємо",
     "uploadButton": "Замовити друк"
   },
   "nav": {
@@ -728,7 +589,7 @@
       "step1": "Надішліть нам STL модель або посилання",
       "step2": "Ми виготовимо її з відповідного матеріалу",
       "step3": "Отримайте посилку на вказану адресу",
-      "step4": "Оцініть роботу та сплатіть зручним способом"
+      "step4": "Ми докладемо всіх зусиль, щоб Ви залишилися задоволені."
     }
   },
   "privacy": {
@@ -846,7 +707,7 @@
       "title": "Оплата",
       "trustModel": {
         "point1": "Оплата після доставки.",
-        "point2": "Ви оцінюєте якість.",
+        "point2": "Ми гарантуємо якість.",
         "point3": "Чесне використання.",
         "point4": "Підтримка включена.",
         "title": "Деталі"
@@ -925,9 +786,24 @@
       "noCommissions": "Без комісій",
       "noPrepayment": "Без передоплати",
       "shipping": "Надсилання поштою",
-      "yourPrice": "Ваша cena"
+      "yourPrice": "Чесна ціна"
     },
     "title": "Чому ми",
     "titleItalic": "довіряємо"
+  },
+  "statuses": {
+    "pending": "В очікуванні",
+    "processing": "В роботі",
+    "shipped": "Відправлено",
+    "completed": "Завершено",
+    "cancelled": "Скасовано",
+    "approved": "Схвалено",
+    "printing": "Друк",
+    "delivered": "Доставлено"
+  },
+  "admin": {
+    "actions": {
+      "deleteOrder": "Видалити замовлення повністю"
+    }
   }
 }

+ 2 - 0
src/main.ts

@@ -1,5 +1,6 @@
 import { createApp } from "vue";
 import { createPinia } from "pinia";
+import { MotionPlugin } from "@vueuse/motion";
 import App from "./App.vue";
 import router from "./router";
 import i18n from "./i18n";
@@ -8,6 +9,7 @@ import "./index.css";
 const app = createApp(App);
 
 app.use(createPinia());
+app.use(MotionPlugin);
 app.use(router);
 app.use(i18n);
 

+ 328 - 62
src/pages/Admin.vue

@@ -25,20 +25,39 @@
       </div>
 
       <!-- Search & actions -->
-      <div class="flex flex-col sm:flex-row gap-4 mb-8">
-        <div class="relative flex-1">
-          <Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
-          <input type="text" v-model="searchQuery" :placeholder="t('admin.searchPlaceholder', { tab: activeTab })"
-            class="w-full bg-card/50 border border-border/50 rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all text-sm" />
+      <div class="flex flex-col gap-4 mb-8">
+        <div class="flex flex-col sm:flex-row gap-4">
+          <div class="relative flex-1">
+            <Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
+            <input type="text" v-model="searchQuery" :placeholder="t('admin.searchPlaceholder', { tab: activeTab })"
+              class="w-full bg-card/50 border border-border/50 rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all text-sm" />
+          </div>
+          <Button v-if="activeTab !== 'orders' && activeTab !== 'audit'" variant="hero" class="gap-2 sm:px-8" @click="handleAddNew">
+            <Plus class="w-4 h-4" />{{ t("admin.addNew") }}
+          </Button>
+        </div>
+        
+        <!-- Filter bar for orders -->
+        <div v-if="activeTab === 'orders'" class="flex flex-wrap items-center gap-4 bg-card/30 p-4 rounded-2xl border border-border/50">
+          <div class="flex items-center gap-2">
+            <Filter class="w-4 h-4 text-muted-foreground" />
+            <span class="text-xs font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.filters") }}</span>
+          </div>
+          <select v-model="statusFilter"
+            class="bg-background border border-border/50 rounded-xl px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary/20 text-xs min-w-[120px]">
+            <option value="all">{{ t("admin.allStatuses") }}</option>
+            <option v-for="s in Object.keys(STATUS_CONFIG)" :key="s" :value="s">{{ t("statuses." + s) }}</option>
+          </select>
+          <div class="flex items-center gap-2 bg-background border border-border/50 rounded-xl px-3 py-1.5">
+             <span class="text-[10px] font-bold text-muted-foreground uppercase mr-1">{{ t("admin.from") }}</span>
+             <input type="date" v-model="dateFrom" class="bg-transparent border-none text-xs focus:ring-0 p-0" />
+          </div>
+          <div class="flex items-center gap-2 bg-background border border-border/50 rounded-xl px-3 py-1.5">
+             <span class="text-[10px] font-bold text-muted-foreground uppercase mr-1">{{ t("admin.to") }}</span>
+             <input type="date" v-model="dateTo" class="bg-transparent border-none text-xs focus:ring-0 p-0" />
+          </div>
+          <button @click="resetFilters" class="text-xs text-muted-foreground hover:text-primary transition-colors underline ml-auto">{{ t("admin.reset") }}</button>
         </div>
-        <select v-if="activeTab === 'orders'" v-model="statusFilter"
-          class="bg-card/50 border border-border/50 rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/20 text-sm min-w-[140px]">
-          <option value="all">{{ t("admin.allStatuses") }}</option>
-          <option v-for="s in Object.keys(STATUS_CONFIG)" :key="s" :value="s">{{ capitalize(s) }}</option>
-        </select>
-        <Button v-if="activeTab !== 'orders'" variant="hero" class="gap-2 sm:px-8" @click="handleAddNew">
-          <Plus class="w-4 h-4" />{{ t("admin.addNew") }}
-        </Button>
       </div>
 
       <!-- Loading -->
@@ -67,7 +86,7 @@
               <div class="flex items-center justify-between mb-4">
                 <span class="text-xs font-bold text-muted-foreground border border-border/50 rounded-full px-2 py-0.5 uppercase tracking-tighter">{{ t("common.orderId", { id: order.id }) }}</span>
                 <span :class="`flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider ${statusColor(order.status)}`">
-                  <component :is="statusIcon(order.status)" class="w-3.5 h-3.5" />{{ t('admin.statuses.' + order.status) }}
+                  <component :is="statusIcon(order.status)" class="w-3.5 h-3.5" />{{ t("statuses." + order.status) }}
                 </span>
               </div>
                 <div class="flex items-center gap-2">
@@ -120,6 +139,12 @@
                       <History class="w-3 h-3" /> {{ t("admin.actions.viewOriginal") }}
                     </button>
                   </div>
+                  
+                  <!-- Manual Fiscal Indicator -->
+                  <div v-if="order.fiscal_qr_url" class="mt-1 flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-emerald-500/10 text-emerald-600 border border-emerald-500/20 w-fit">
+                    <CheckCircle2 class="w-3 h-3" />
+                    <span class="text-[9px] font-bold uppercase">{{ t("admin.fields.fiscalized") }}</span>
+                  </div>
                 </div>
                 <div class="text-right">
                   <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1 block">{{ t("admin.fields.quantity") }}</span>
@@ -159,7 +184,8 @@
                   </div>
                 </div>
                 <div class="grid gap-3">
-                  <div v-for="(f, i) in order.files" :key="i" class="relative group/file bg-background/30 border border-border/50 rounded-2xl overflow-hidden hover:border-primary/30 transition-all flex h-20">
+                  <template v-for="(f, i) in order.files" :key="f.id || i">
+                    <div v-if="f.id" class="relative group/file bg-background/30 border border-border/50 rounded-2xl overflow-hidden hover:border-primary/30 transition-all flex h-20">
                     <!-- Preview -->
                     <div class="w-20 bg-muted/20 flex items-center justify-center border-r border-border/50 overflow-hidden">
                        <img v-if="f.preview_path" :src="`http://localhost:8000/${f.preview_path}`" class="w-full h-full object-contain p-1" />
@@ -172,7 +198,7 @@
                     <div class="flex-1 p-3 flex flex-col justify-center min-w-0">
                       <p class="text-[11px] font-bold truncate mb-1 pr-4">{{ f.filename }}</p>
                       <div class="flex flex-wrap gap-2 items-center">
-                        <span class="text-[9px] text-muted-foreground uppercase opacity-60">{{ (f.file_size / 1024 / 1024).toFixed(1) }} MB</span>
+                        <span v-if="f.file_size" class="text-[9px] text-muted-foreground uppercase opacity-60">{{ (f.file_size / 1024 / 1024).toFixed(1) }} MB</span>
                         <div v-if="f.print_time || f.filament_g" class="flex gap-2 border-l border-border/50 pl-2">
                           <span v-if="f.print_time" class="text-[9px] font-bold text-primary/80">⏱️ {{ f.print_time }}</span>
                           <span v-if="f.filament_g" class="text-[9px] font-bold text-primary/80">⚖️ {{ f.filament_g.toFixed(1) }}g</span>
@@ -182,12 +208,13 @@
                     <!-- Delete Actions & Quantity -->
                     <div class="absolute top-2 right-2 flex flex-col items-end gap-1">
                       <div class="px-1.5 py-0.5 bg-background/80 backdrop-blur-md rounded-md border border-border/50 text-[10px] font-bold">x{{ f.quantity || 1 }}</div>
-                      <button @click.prevent="handleDeleteFile(order.id, f.file_id, f.filename)" class="p-1 bg-background/80 backdrop-blur-md border border-border/50 text-rose-500 hover:bg-rose-500 hover:text-white rounded-md transition-colors shadow-sm" :title="t('admin.actions.deleteFile')">
+                      <button @click.prevent="handleDeleteFile(order.id, f.id, f.filename)" class="p-1 bg-background/80 backdrop-blur-md border border-border/50 text-rose-500 hover:bg-rose-500 hover:text-white rounded-md transition-colors shadow-sm" :title="t('admin.actions.deleteFile')">
                         <Trash2 class="w-2.5 h-2.5" />
                       </button>
                     </div>
                   </div>
-                </div>
+                </template>
+              </div>
               <div class="pt-4 border-t border-border/50">
                 <div class="flex items-center justify-between mb-3">
                   <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.photoReport") }} ({{ order.photos?.length || 0 }})</span>
@@ -228,7 +255,7 @@
                 <button v-for="s in Object.keys(STATUS_CONFIG)" :key="s"
                   @click="handleUpdateStatus(order.id, s)"
                   :class="`text-[9px] font-bold uppercase py-1.5 rounded-lg border transition-all ${order.status === s ? 'bg-primary text-primary-foreground border-primary' : 'bg-background hover:border-primary/30 border-border/50'}`">
-                  {{ s }}
+                  {{ t("statuses." + s) }}
                 </button>
               </div>
               <div class="pt-4 border-t border-border/50">
@@ -238,19 +265,65 @@
                 </div>
                 <div class="flex justify-between items-center">
                   <span class="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">{{ t("admin.fields.finalPrice") }}</span>
-                  <button @click="editingPrice = { id: order.id, price: order.total_price?.toString() ?? '' }" class="text-[10px] text-primary hover:underline font-bold">{{ t("admin.actions.edit") }}</button>
+                  <button @click="openItemsModal(order.id)" class="text-[10px] text-primary hover:underline font-bold">{{ t("admin.actions.edit") }}</button>
                 </div>
-                <div class="text-2xl font-display font-bold">{{ order.total_price || "---" }} <span class="text-xs">EUR</span></div>
+                <div class="text-2xl font-display font-bold">{{ (order.total_price || 0) }} <span class="text-xs">EUR</span></div>
                 
+                <a v-if="order.proforma_path" :href="`http://localhost:8000/${order.proforma_path}`" target="_blank"
+                   class="mt-4 w-full flex items-center justify-center gap-2 py-2 rounded-xl bg-orange-500/10 hover:bg-orange-500/20 text-orange-600 border border-orange-500/20 font-bold transition-all text-sm">
+                  <FileText class="w-4 h-4" /> {{ t("admin.actions.printProforma") }}
+                </a>
+
                 <a v-if="order.invoice_path" :href="`http://localhost:8000/${order.invoice_path}`" target="_blank"
-                   class="mt-4 w-full flex items-center justify-center gap-2 py-2 rounded-xl bg-primary/10 hover:bg-primary/20 text-primary border border-primary/20 font-bold transition-all text-sm">
+                   class="mt-2 w-full flex items-center justify-center gap-2 py-2 rounded-xl bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-600 border border-emerald-500/20 font-bold transition-all text-sm">
                   <FileText class="w-4 h-4" /> {{ t("admin.actions.printInvoice") }}
                 </a>
 
-                <button @click="toggleAdminChat(order.id)" :class="['w-full flex items-center justify-center gap-2 py-2 rounded-xl border border-border/50 hover:border-primary/30 text-sm font-bold transition-all relative', order.invoice_path ? 'mt-2' : 'mt-4', adminChatId === order.id ? 'bg-primary text-primary-foreground' : 'bg-background/50 text-muted-foreground hover:text-foreground']">
+                <button @click="toggleAdminChat(order.id)" :class="['w-full flex items-center justify-center gap-2 py-2 rounded-xl border border-border/50 hover:border-primary/30 text-sm font-bold transition-all relative', (order.invoice_path || order.proforma_path) ? 'mt-2' : 'mt-4', adminChatId === order.id ? 'bg-primary text-primary-foreground' : 'bg-background/50 text-muted-foreground hover:text-foreground']">
                   <span v-if="order.unread_count > 0" class="absolute -top-2 -right-2 bg-red-500 text-white text-[10px] w-5 h-5 flex items-center justify-center rounded-full shadow-sm animate-pulse z-10">{{ order.unread_count }}</span>
                   <MessageCircle class="w-4 h-4" />{{ t("chat.open") }}
                 </button>
+
+                <!-- Fiscalization Data Entry -->
+                <div class="mt-6 pt-6 border-t border-border/50">
+                  <div class="flex items-center justify-between mb-3">
+                    <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("admin.fields.fiscalization") }}</span>
+                    <button v-if="order.status === 'shipped' || order.invoice_path" @click="handleForceGenerateInvoice(order.id)" class="text-[9px] text-primary hover:underline font-bold uppercase">
+                      {{ t("admin.actions.regenerateInvoice") }}
+                    </button>
+                  </div>
+                  <div class="space-y-3">
+                    <div class="space-y-1">
+                      <label class="text-[9px] font-bold uppercase text-muted-foreground ml-1">EFI Verification URL</label>
+                      <div class="flex gap-2">
+                        <input v-model="fiscalFormMap[order.id].fiscal_qr_url" 
+                          class="flex-1 min-w-0 bg-background border border-border/50 rounded-lg px-3 py-1.5 text-[10px] font-mono focus:ring-1 ring-primary/30 outline-none" 
+                          placeholder="https://efi.porezi.me/verify/..." />
+                        <button @click="handleUpdateFiscal(order.id)" class="p-1.5 bg-primary/10 text-primary hover:bg-primary hover:text-primary-foreground rounded-lg transition-all">
+                          <Save class="w-3.5 h-3.5" />
+                        </button>
+                      </div>
+                    </div>
+                    <div class="grid grid-cols-2 gap-2">
+                       <div class="space-y-1">
+                         <label class="text-[9px] font-bold uppercase text-muted-foreground ml-1">IKOF</label>
+                         <input v-model="fiscalFormMap[order.id].ikof" class="w-full bg-background border border-border/50 rounded-lg px-2 py-1.5 text-[10px] font-mono outline-none" />
+                       </div>
+                       <div class="space-y-1">
+                         <label class="text-[9px] font-bold uppercase text-muted-foreground ml-1">JIKR</label>
+                         <input v-model="fiscalFormMap[order.id].jikr" class="w-full bg-background border border-border/50 rounded-lg px-2 py-1.5 text-[10px] font-mono outline-none" />
+                       </div>
+                    </div>
+                  </div>
+                </div>
+
+                <!-- Danger Zone -->
+                <div class="mt-8 pt-6 border-t border-rose-500/10 text-center">
+                  <button @click="handleDeleteOrder(order.id)" 
+                    class="w-full flex items-center justify-center gap-2 py-2 rounded-xl bg-rose-500/5 hover:bg-rose-500/10 text-rose-500/60 hover:text-rose-500 border border-transparent hover:border-rose-500/20 font-bold transition-all text-xs group">
+                    <Trash2 class="w-3.5 h-3.5 transition-transform group-hover:scale-110" /> {{ t("admin.actions.deleteOrder") }}
+                  </button>
+                </div>
               </div>
             </div>
           </div>
@@ -525,20 +598,51 @@
 
     <!-- ——— MODALS ——— -->
     <Teleport to="body">
-      <!-- Price Modal -->
+      <!-- Order Items / Specification Modal -->
       <Transition enter-active-class="transition duration-150" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
-        <div v-if="editingPrice" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
-          <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="editingPrice = null" />
-          <div class="relative w-full max-w-sm bg-card border border-border/50 rounded-3xl p-8 shadow-2xl">
-            <h3 class="text-xl font-bold font-display mb-6">{{ t("admin.fields.updateFinalPrice") }}</h3>
-            <div class="space-y-4">
-              <input v-model="editingPrice.price" type="number" step="0.01"
-                class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 font-bold" />
-              <div class="flex gap-3">
-                <Button variant="ghost" class="flex-1" @click="editingPrice = null">Cancel</Button>
-                <Button variant="hero" class="flex-1" @click="handleUpdatePrice">{{ t("admin.actions.savePrice") }}</Button>
+        <div v-if="editingItems" class="fixed inset-0 z-[100] flex items-center justify-center p-4">
+          <div class="absolute inset-0 bg-background/80 backdrop-blur-sm" @click="editingItems = null" />
+          <div class="relative w-full max-w-2xl bg-card border border-border/50 rounded-3xl p-8 shadow-2xl maxHeight-[90vh] flex flex-col">
+            <h3 class="text-xl font-bold font-display mb-6">Order Specification & Pricing</h3>
+            
+            <div class="flex-1 overflow-y-auto space-y-4 mb-6 pr-2">
+              <div v-for="(item, idx) in editingItems.items" :key="idx" class="grid grid-cols-[1fr,80px,100px,40px] gap-3 items-end">
+                <div class="space-y-1">
+                  <label v-if="idx===0" class="text-[10px] font-bold uppercase ml-1 opacity-50">Description</label>
+                  <input v-model="item.description" type="text" placeholder="Item description" class="w-full bg-background border border-border/50 rounded-xl px-3 py-2 text-sm" />
+                </div>
+                <div class="space-y-1">
+                  <label v-if="idx===0" class="text-[10px] font-bold uppercase ml-1 opacity-50">Qty</label>
+                  <input v-model.number="item.quantity" type="number" class="w-full bg-background border border-border/50 rounded-xl px-2 py-2 text-sm text-center" />
+                </div>
+                <div class="space-y-1">
+                  <label v-if="idx===0" class="text-[10px] font-bold uppercase ml-1 opacity-50">Unit Price</label>
+                  <div class="relative">
+                    <input v-model.number="item.unit_price" type="number" step="0.01" class="w-full bg-background border border-border/50 rounded-xl pl-6 pr-2 py-2 text-sm text-right" />
+                    <span class="absolute left-2 top-1/2 -translate-y-1/2 text-[10px] opacity-30">€</span>
+                  </div>
+                </div>
+                <button @click="removeItemRow(idx)" class="p-2 text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors mb-0.5">
+                  <Trash2 class="w-4 h-4" />
+                </button>
               </div>
+              
+              <button @click="addItemRow" class="w-full py-3 border border-dashed border-border/50 rounded-xl text-primary text-xs font-bold hover:bg-primary/5 transition-all">
+                + Add Custom Item
+              </button>
+            </div>
+
+            <div class="flex items-center justify-between pt-6 border-t border-border/50">
+               <div>
+                  <span class="text-[10px] uppercase font-bold text-muted-foreground block">Total Amount</span>
+                  <span class="text-2xl font-bold font-display">{{ editingItems.items.reduce((sum, i) => (sum || 0) + ((i.quantity || 0) * (i.unit_price || 0)), 0).toFixed(2) }} EUR</span>
+               </div>
+               <div class="flex gap-3">
+                 <Button variant="ghost" @click="editingItems = null">Cancel</Button>
+                 <Button variant="hero" @click="handleSaveItems">Save Specification</Button>
+               </div>
             </div>
+
           </div>
         </div>
       </Transition>
@@ -655,6 +759,14 @@
                 <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">{{ t("admin.fields.description") }} (ME)</label><textarea v-model="matForm.desc_me" required class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[60px]" /></div>
               </div>
 
+              <!-- Big Descriptions -->
+              <div class="space-y-4 pt-4 border-t border-border/30">
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Full Site Description (EN)</label><textarea v-model="matForm.long_desc_en" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[120px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Полное описание для сайта (RU)</label><textarea v-model="matForm.long_desc_ru" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[120px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Повний опис для сайту (UA)</label><textarea v-model="matForm.long_desc_ua" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[120px]" /></div>
+                <div class="space-y-1"><label class="text-[10px] font-bold uppercase ml-1">Dugi opis za sajt (ME)</label><textarea v-model="matForm.long_desc_me" class="w-full bg-background border border-border/50 rounded-xl px-4 py-3 min-h-[120px]" /></div>
+              </div>
+
               <div class="flex gap-3 pt-4"><Button type="button" variant="ghost" class="flex-1" @click="closeModals">Cancel</Button><Button type="submit" variant="hero" class="flex-1">Save Changes</Button></div>
             </form>
           </div>
@@ -794,10 +906,11 @@
 
 <script setup lang="ts">
 import { ref, computed, watch, reactive, onMounted, onUnmounted, nextTick } from "vue";
-import { RouterLink, useRouter } from "vue-router";
+import { RouterLink, useRouter, useRoute } from "vue-router";
 import { useI18n } from "vue-i18n";
+import { loadAdminTranslations } from "@/i18n";
 import { toast } from "vue-sonner";
-import { Package, Clock, CheckCircle2, Truck, XCircle, AlertCircle, FileText, ExternalLink, ShieldCheck, Eye, RefreshCw, Search, Filter, Layers, Settings, Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Database, Image as ImageIcon, EyeOff, Hash, MessageCircle, FileBox, Download, Newspaper, History, X, Users } from "lucide-vue-next";
+import { Package, Clock, CheckCircle2, Truck, XCircle, AlertCircle, FileText, ExternalLink, ShieldCheck, Eye, RefreshCw, Search, Filter, Layers, Settings, Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Database, Image as ImageIcon, EyeOff, Hash, MessageCircle, FileBox, Download, Newspaper, History, X, Users, Save } from "lucide-vue-next";
 import Button from "@/components/ui/button.vue";
 import Header from "@/components/Header.vue";
 import Footer from "@/components/Footer.vue";
@@ -806,11 +919,13 @@ import { useAuthStore } from "@/stores/auth";
 import { 
   adminGetOrders, adminUpdateOrder, adminGetMaterials, adminCreateMaterial, adminUpdateMaterial, adminDeleteMaterial, 
   adminGetServices, adminCreateService, adminUpdateService, adminDeleteService, adminUploadOrderPhoto, 
-  adminUpdatePhotoStatus, adminDeletePhoto, adminGetAllPhotos, adminAttachFile, adminDeleteFile, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost, adminUpdateUser, adminGetUsers, adminCreateUser, adminGetAuditLogs 
+  adminUpdatePhotoStatus, adminDeletePhoto, adminGetAllPhotos, adminAttachFile, adminDeleteFile, adminDeleteOrder, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost, adminUpdateUser, adminGetUsers, adminCreateUser, adminGetAuditLogs,
+  adminGetOrderItems, adminUpdateOrderItems
 } from "@/lib/api";
 
 const { t, locale } = useI18n();
 const router = useRouter();
+const route = useRoute();
 const authStore = useAuthStore();
 
 const STATUS_CONFIG: Record<string, { color: string; icon: any }> = {
@@ -830,6 +945,11 @@ async function toggleAdminChat(orderId: number) {
   adminChatId.value = isOpening ? orderId : null;
   
   if (isOpening) {
+    // Instantly clear local unread count for UI snappiness
+    const order = orders.value.find(o => o.id === orderId);
+    if (order) order.unread_count = 0;
+    authStore.refreshUnreadCount();
+
     await nextTick();
     const el = document.getElementById(`admin-chat-${orderId}`);
     if (el) {
@@ -849,7 +969,26 @@ const tabs: { id: Tab; icon: any }[] = [
 ];
 
 type Tab = "orders" | "materials" | "services" | "posts" | "users" | "portfolio" | "audit";
-const activeTab    = ref<Tab>("orders");
+
+const getValidTab = (val: any): Tab => {
+  const t = val?.toString();
+  return ["orders", "materials", "services", "posts", "users", "portfolio", "audit"].includes(t) ? t : "orders";
+};
+
+const activeTab = ref<Tab>(getValidTab(route.query.tab));
+
+watch(activeTab, (newTab) => {
+  if (route.query.tab !== newTab) {
+    router.replace({ query: { ...route.query, tab: newTab } });
+  }
+});
+
+watch(() => route.query.tab, (newTab) => {
+  const valid = getValidTab(newTab);
+  if (valid !== activeTab.value) {
+    activeTab.value = valid;
+  }
+});
 const orders       = ref<any[]>([]);
 const materials    = ref<any[]>([]);
 const services     = ref<any[]>([]);
@@ -875,6 +1014,16 @@ const userPage     = ref(1);
 const isLoading    = ref(true);
 const searchQuery  = ref("");
 const statusFilter = ref("all");
+const dateFrom     = ref("");
+const dateTo       = ref("");
+
+function resetFilters() {
+  searchQuery.value = "";
+  statusFilter.value = "all";
+  dateFrom.value = "";
+  dateTo.value = "";
+}
+
 const editingPrice    = ref<{ id: number; price: string } | null>(null);
 const editingParams   = ref<{ id: number; material_id: number; color_name: string; quantity: number } | null>(null);
 const focusedOrderId = ref<number | null>(null);
@@ -884,8 +1033,9 @@ const editingService  = ref<any | null>(null);
 const editingPost     = ref<any | null>(null);
 const showAddModal    = ref(false);
 const notifyStatusMap = ref<Record<number, boolean>>({});
+const fiscalFormMap = ref<Record<number, { fiscal_qr_url: string; ikof: string; jikr: string }>>({});
 
-const matForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", price_per_cm3: 0, available_colors: [] as string[], is_active: true });
+const matForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", long_desc_en: "", long_desc_ru: "", long_desc_ua: "", long_desc_me: "", price_per_cm3: 0, available_colors: [] as string[], is_active: true });
 const newColor = ref("");
 const svcForm = reactive({ name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", tech_type: "", is_active: true });
 const postForm = reactive({
@@ -904,12 +1054,12 @@ const userForm = reactive({
   phone: ""
 });
 
-const filteredOrders = computed(() => orders.value.filter(o => {
-  const qs = searchQuery.value.toLowerCase();
-  const matchSearch = o.email?.toLowerCase().includes(qs) || o.first_name?.toLowerCase().includes(qs) || o.last_name?.toLowerCase().includes(qs) || String(o.id).includes(qs);
-  const matchStatus = statusFilter.value === "all" || o.status === statusFilter.value;
-  return matchSearch && matchStatus;
-}));
+const filteredOrders = computed(() => {
+  if (activeTab.value !== 'orders') return [];
+  // Since we now use server-side filtering, we just return orders.value
+  // But we can still do a final local filter if the API didn't cover something
+  return orders.value;
+});
 
 const selectedMatColors = computed(() => {
   if (!editingParams.value?.material_id) return [];
@@ -936,9 +1086,23 @@ async function fetchData() {
   try {
     const currentTab = activeTab.value;
     if (currentTab === "orders") {
-      orders.value = await adminGetOrders();
+      orders.value = await adminGetOrders({
+        search: searchQuery.value,
+        status: statusFilter.value,
+        date_from: dateFrom.value,
+        date_to: dateTo.value
+      });
       materials.value = await adminGetMaterials(); // Needed for changing order params
-      orders.value.forEach(o => { if (notifyStatusMap.value[o.id] === undefined) notifyStatusMap.value[o.id] = true; });
+      orders.value.forEach(o => { 
+        if (notifyStatusMap.value[o.id] === undefined) notifyStatusMap.value[o.id] = true;
+        if (!fiscalFormMap.value[o.id]) {
+          fiscalFormMap.value[o.id] = {
+            fiscal_qr_url: o.fiscal_qr_url || "",
+            ikof: o.ikof || "",
+            jikr: o.jikr || ""
+          };
+        }
+      });
     }
     else if (currentTab === "materials") materials.value = await adminGetMaterials();
     else if (currentTab === "services")  services.value  = await adminGetServices();
@@ -966,6 +1130,20 @@ watch([userPage, userSearch], () => {
   if (activeTab.value === 'users') fetchUsers();
 });
 
+watch([searchQuery, statusFilter, dateFrom, dateTo], () => {
+  if (activeTab.value === 'orders') {
+    debouncedFetchOrders();
+  }
+});
+
+let fetchTimeout: any = null;
+function debouncedFetchOrders() {
+  clearTimeout(fetchTimeout);
+  fetchTimeout = setTimeout(() => {
+    fetchData();
+  }, 400);
+}
+
 watch(auditPage, () => {
   if (activeTab.value === 'audit') fetchAuditLogs();
 });
@@ -973,6 +1151,7 @@ watch(auditPage, () => {
 watch(activeTab, fetchData, { immediate: false });
 onMounted(async () => {
   if (!authStore.user || authStore.user.role !== "admin") { router.push("/auth"); return; }
+  await loadAdminTranslations();
   fetchData();
 });
 
@@ -990,24 +1169,96 @@ function handlePaste(event: ClipboardEvent) {
   }
 }
 
+function handleOrderReadEvent(event: any) {
+  const orderId = event.detail?.order_id;
+  if (orderId) {
+    const order = orders.value.find(o => o.id === orderId);
+    if (order) order.unread_count = 0;
+  }
+}
+
 onMounted(() => {
   window.addEventListener('paste', handlePaste);
+  window.addEventListener('radionica:order_read', handleOrderReadEvent);
 });
 onUnmounted(() => {
   window.removeEventListener('paste', handlePaste);
+  window.removeEventListener('radionica:order_read', handleOrderReadEvent);
 });
 
 async function handleUpdateStatus(id: number, status: string) {
   const notify = notifyStatusMap.value[id] ?? true; // True by default
-  try { await adminUpdateOrder(id, { status, send_notification: notify }); toast.success(t("admin.toasts.statusUpdated", { status })); fetchData(); }
+  try { 
+    await adminUpdateOrder(id, { status, send_notification: notify }); 
+    toast.success(t("admin.toasts.statusUpdated", { status })); 
+    
+    if (status === 'shipped') {
+      toast.info(t("admin.toasts.invoiceReminder"), { duration: 6000 });
+    }
+    
+    fetchData(); 
+  }
   catch { toast.error(t("admin.toasts.genericError")); }
 }
-async function handleUpdatePrice() {
-  if (!editingPrice.value) return;
-  const p = parseFloat(editingPrice.value.price);
-  if (isNaN(p)) { toast.error(t("admin.toasts.genericError")); return; }
-  try { await adminUpdateOrder(editingPrice.value.id, { total_price: p }); toast.success(t("admin.toasts.priceUpdated")); editingPrice.value = null; fetchData(); }
-  catch { toast.error(t("admin.toasts.genericError")); }
+
+async function handleUpdateFiscal(orderId: number) {
+  const form = fiscalFormMap.value[orderId];
+  if (!form) return;
+  try {
+    await adminUpdateOrder(orderId, {
+      fiscal_qr_url: form.fiscal_qr_url,
+      ikof: form.ikof,
+      jikr: form.jikr
+    });
+    toast.success(t("admin.toasts.fiscalUpdated"));
+    fetchData();
+  } catch (err: any) {
+    toast.error(err.message || t("admin.toasts.genericError"));
+  }
+}
+
+async function handleForceGenerateInvoice(orderId: number) {
+  try {
+    // Regenerating invoice by re-triggering 'shipped' logic on the backend
+    await adminUpdateOrder(orderId, { status: 'shipped', send_notification: false });
+    toast.success(t("admin.toasts.invoiceRegenerated"));
+    fetchData();
+  } catch (err: any) {
+    toast.error(err.message || t("admin.toasts.genericError"));
+  }
+}
+const editingItems = ref<{ order_id: number; items: any[] } | null>(null);
+
+async function openItemsModal(orderId: number) {
+  try {
+    const items = await adminGetOrderItems(orderId);
+    editingItems.value = { 
+      order_id: orderId, 
+      items: items.length > 0 ? items.map((i:any) => ({ ...i })) : [{ description: '3D Printing Service', quantity: 1, unit_price: 0 }] 
+    };
+  } catch (err) {
+    toast.error("Failed to fetch items");
+  }
+}
+
+function addItemRow() {
+  editingItems.value?.items.push({ description: '', quantity: 1, unit_price: 0 });
+}
+
+function removeItemRow(idx: number) {
+  editingItems.value?.items.splice(idx, 1);
+}
+
+async function handleSaveItems() {
+  if (!editingItems.value) return;
+  try {
+    await adminUpdateOrderItems(editingItems.value.order_id, editingItems.value.items);
+    toast.success(t("admin.toasts.priceUpdated"));
+    editingItems.value = null;
+    fetchData();
+  } catch (err: any) {
+    toast.error(err.message || t("admin.toasts.genericError"));
+  }
 }
 async function handleUpdateParams() {
   if (!editingParams.value) return;
@@ -1053,10 +1304,25 @@ async function handleAttachFile(orderId: number, file?: File) {
   try { await adminAttachFile(orderId, fd); toast.success(t("admin.toasts.fileAttached")); fetchData(); }
   catch (e: any) { toast.error(e.message); }
 }
-async function handleDeleteFile(orderId: number, fileId: number, filename: string) {
-  if (!window.confirm(`Delete attached file "${filename}"?`)) return;
-  try { await adminDeleteFile(orderId, fileId); toast.success(t("admin.toasts.fileDeleted")); fetchData(); }
-  catch { toast.error(t("admin.toasts.genericError")); }
+async function handleDeleteFile(orderId: number, file_id: number, filename: string) {
+  if (!confirm(t("admin.questions.deleteFile", { filename }))) return;
+  try {
+    await adminDeleteFile(orderId, file_id);
+    toast.success(t("admin.toasts.fileDeleted"));
+    fetchData();
+  } catch (err: any) {
+    toast.error(err.message);
+  }
+}
+async function handleDeleteOrder(orderId: number) {
+  if (!confirm(`DANGER: Are you sure you want to PERMANENTLY delete Order #${orderId}? This will remove all files, messages, and photos. This action cannot be undone.`)) return;
+  try {
+    await adminDeleteOrder(orderId);
+    toast.success(`Order #${orderId} deleted perfectly.`);
+    fetchData();
+  } catch (err: any) {
+    toast.error(err.message);
+  }
 }
 async function handleDeleteMaterial(id: number, name: string) {
   if (!window.confirm(`Delete material "${name}"?`)) return;
@@ -1079,7 +1345,7 @@ async function togglePostActive(p: any)     { await adminUpdatePost(p.id,    { .
 
 function handleAddNew() {
   if (activeTab.value === 'materials') {
-    Object.assign(matForm, { name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", price_per_cm3: 0, available_colors: [], is_active: true });
+    Object.assign(matForm, { name_en: "", name_ru: "", name_ua: "", name_me: "", desc_en: "", desc_ru: "", desc_ua: "", desc_me: "", long_desc_en: "", long_desc_ru: "", long_desc_ua: "", long_desc_me: "", price_per_cm3: 0, available_colors: [], is_active: true });
     newColor.value = "";
     editingMaterial.value = null;
   } else if (activeTab.value === 'services') {
@@ -1158,14 +1424,14 @@ async function handleUpdateUserRole(userId: number, role: string) {
   }
 }
 
-function closeModals() { editingMaterial.value = null; editingService.value = null; editingPost.value = null; showAddModal.value = false; editingPrice.value = null; editingParams.value = null; viewingOriginal.value = null; }
+function closeModals() { editingMaterial.value = null; editingService.value = null; editingPost.value = null; showAddModal.value = false; editingItems.value = null; editingParams.value = null; viewingOriginal.value = null; }
 
 watch(editingMaterial, m => { 
   if (m) {
-    Object.assign(matForm, m);
+    Object.assign(matForm, { ...m, is_active: !!m.is_active });
     newColor.value = "";
   }
 });
-watch(editingService,  s => { if (s) Object.assign(svcForm, s); });
+watch(editingService,  s => { if (s) Object.assign(svcForm, { ...s, is_active: !!s.is_active }); });
 watch(editingPost,     p => { if (p) Object.assign(postForm, p); });
 </script>

+ 67 - 11
src/pages/Orders.vue

@@ -102,6 +102,24 @@
               <p class="text-xs text-muted-foreground italic leading-relaxed">"{{ order.notes }}"</p>
             </div>
 
+            <!-- Items Specification -->
+            <div v-if="order.items && order.items.length > 0" class="mt-8 p-4 bg-secondary/20 border border-border/50 rounded-2xl relative z-10">
+              <div class="flex items-center gap-2 mb-4">
+                <Hash class="w-3.5 h-3.5 text-primary" />
+                <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("orders.labels.specification") }}</span>
+              </div>
+              <div class="space-y-2">
+                <div v-for="item in order.items" :key="item.id" class="flex justify-between items-center text-xs">
+                  <span class="text-muted-foreground">{{ item.description }} <span v-if="item.quantity > 1" class="text-primary font-bold">x{{ item.quantity }}</span></span>
+                  <span class="font-bold">{{ item.total_price }} €</span>
+                </div>
+                <div class="pt-2 mt-2 border-t border-border/50 flex justify-between items-center font-bold">
+                  <span class="text-[10px] uppercase tracking-wider">{{ t("orders.labels.total") }}</span>
+                  <span class="text-lg text-primary">{{ order.total_price }} €</span>
+                </div>
+              </div>
+            </div>
+
             <!-- Pizza Tracker -->
             <div class="mt-8 pt-6 border-t border-border/50 relative z-10 px-2 sm:px-8">
               <OrderTracker :status="order.status" />
@@ -114,8 +132,9 @@
                 <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("orders.labels.projectFiles") }}</span>
               </div>
               <div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
-                <div v-for="file in order.files" :key="file.id" 
-                  class="bg-background/40 border border-border/50 rounded-2xl overflow-hidden group/file hover:border-primary/30 transition-all flex flex-col h-full">
+                <template v-for="file in order.files" :key="file.id">
+                  <div v-if="file.id" 
+                    class="bg-background/40 border border-border/50 rounded-2xl overflow-hidden group/file hover:border-primary/30 transition-all flex flex-col h-full">
                   <div class="aspect-square bg-muted/20 flex items-center justify-center p-2 relative overflow-hidden">
                     <img v-if="file.preview_path" :src="`http://localhost:8000/${file.preview_path}`" class="w-full h-full object-contain" />
                     <FileBox v-else class="w-8 h-8 text-muted-foreground/20" />
@@ -126,7 +145,6 @@
                   <div class="p-3">
                     <p class="text-[10px] font-bold truncate">{{ file.filename }}</p>
                     <div class="flex items-center gap-2 mt-1">
-                      <span class="text-[8px] text-muted-foreground bg-white/5 px-1 py-0.5 rounded uppercase">{{ (file.file_size / 1024 / 1024).toFixed(1) }} MB</span>
                       <span v-if="file.quantity > 1" class="text-[8px] font-bold text-primary">x{{ file.quantity }}</span>
                     </div>
                     <div v-if="file.print_time || file.filament_g" class="flex flex-col gap-0.5 mt-2 pt-2 border-t border-border/10">
@@ -134,7 +152,7 @@
                       <span v-if="file.filament_g" class="text-[8px] text-primary/80">⚖️ {{ file.filament_g.toFixed(1) }}g</span>
                     </div>
                   </div>
-                </div>
+                </template>
               </div>
             </div>
 
@@ -160,6 +178,17 @@
             </div>
           </div>
         </div>
+
+        <!-- Pagination -->
+        <div v-if="totalOrders > pageSize" class="flex flex-wrap items-center justify-center gap-2 mt-12 mb-8">
+          <button v-for="p in Math.ceil(totalOrders / pageSize)" :key="p"
+            @click="currentPage = p"
+            v-motion :initial="{ opacity: 0, scale: 0.9 }" :enter="{ opacity: 1, scale: 1, transition: { delay: p * 50 } }"
+            :class="['w-10 h-10 rounded-xl font-bold transition-all border', 
+              currentPage === p ? 'bg-primary text-primary-foreground border-primary shadow-glow' : 'bg-card/40 border-border/50 text-muted-foreground hover:border-primary/30']">
+            {{ p }}
+          </button>
+        </div>
       </div>
     </main>
     <Footer />
@@ -168,7 +197,7 @@
 
 <script setup lang="ts">
 import { ref, onMounted, defineComponent, h, watch, nextTick } from "vue";
-import { RouterLink, useRouter } from "vue-router";
+import { RouterLink, useRouter, useRoute } from "vue-router";
 import { useI18n } from "vue-i18n";
 import { Package, Clock, ShieldCheck, Truck, XCircle, ArrowLeft, Loader2, ExternalLink, Hash, FileText, Image as ImageIcon, MessageCircle, FileBox, Download } from "lucide-vue-next";
 import Button from "@/components/ui/button.vue";
@@ -181,16 +210,25 @@ import { useAuthStore } from "@/stores/auth";
 
 const { t } = useI18n();
 const router = useRouter();
+const route = useRoute();
 const authStore = useAuthStore();
 const orders = ref<any[]>([]);
+const totalOrders = ref(0);
+const currentPage = ref(1);
+const pageSize = 10;
 const isLoading = ref(true);
-const openChatId = ref<number | null>(null);
+const openChatId = ref<number | null>(route.query.chat ? parseInt(route.query.chat.toString()) : null);
 
 async function toggleChat(orderId: number) {
   const isOpening = openChatId.value !== orderId;
   openChatId.value = isOpening ? orderId : null;
   
   if (isOpening) {
+    // Clear unread count locally for instant UI feedback
+    const order = orders.value.find(o => o.id === orderId);
+    if (order) order.unread_count = 0;
+    authStore.refreshUnreadCount();
+
     await nextTick();
     const el = document.getElementById(`chat-${orderId}`);
     if (el) {
@@ -199,6 +237,12 @@ async function toggleChat(orderId: number) {
   }
 }
 
+watch(openChatId, (newId) => {
+  if (route.query.chat?.toString() !== newId?.toString()) {
+    router.replace({ query: { ...route.query, chat: newId || undefined } });
+  }
+});
+
 // StatusBadge component defined inline
 const StatusBadge = defineComponent({
   props: { status: String },
@@ -216,7 +260,7 @@ const StatusBadge = defineComponent({
       const Icon = icons[s] || Clock;
       return h("span", { class: `inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold border ${styles[s] ?? styles.pending}` }, [
         h(Icon, { class: "w-3.5 h-3.5" }),
-        h("span", { class: "capitalize" }, s),
+        h("span", null, t("statuses." + s)),
       ]);
     };
   },
@@ -226,19 +270,31 @@ function formatDate(dt: string) {
   return new Date(dt).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
 }
 
-onMounted(async () => {
-  if (!localStorage.getItem("token")) { router.push("/auth"); return; }
+async function fetchOrders() {
+  isLoading.value = true;
   try { 
-    orders.value = await getMyOrders(); 
+    const res = await getMyOrders(currentPage.value, pageSize); 
+    orders.value = res.orders;
+    totalOrders.value = res.total;
   }
   catch (e) { console.error("Failed to fetch orders:", e); }
   finally { isLoading.value = false; }
+}
+
+onMounted(async () => {
+  if (!localStorage.getItem("token")) { router.push("/auth"); return; }
+  await fetchOrders();
+});
+
+watch(currentPage, () => {
+  fetchOrders();
+  window.scrollTo({ top: 0, behavior: 'smooth' });
 });
 
 watch(() => authStore.unreadMessagesCount, async (newVal, oldVal) => {
   if (newVal !== oldVal) {
     try {
-      orders.value = await getMyOrders();
+      await fetchOrders();
     } catch (e) {
       console.error("Failed to auto-refresh orders:", e);
     }

+ 1 - 1
src/pages/Portfolio.vue

@@ -79,7 +79,7 @@
 <script setup lang="ts">
 import { ref, onMounted } from "vue";
 import { useI18n } from "vue-i18n";
-import { Sparkles, Loader2, Camera, ExternalLink } from "lucide-vue-next";
+import { Sparkles, Loader2, Camera, ExternalLink, RefreshCw, Image as ImageIcon } from "lucide-vue-next";
 import Header from "@/components/Header.vue";
 import Footer from "@/components/Footer.vue";
 import { getPortfolio } from "@/lib/api";

+ 52 - 26
src/router/index.ts

@@ -1,28 +1,38 @@
-import { createRouter, createWebHistory } from "vue-router";
+import { createRouter, createWebHistory, RouterView } from "vue-router";
+import { h } from "vue";
+import i18n, { setLanguage } from "@/i18n";
+
+const supportedLangs = ['en', 'ru', 'me', 'ua'];
+
+const routes = [
+  {
+    path: "/:lang(en|ru|me|ua)?",
+    component: { render: () => h(RouterView) },
+    children: [
+      { path: "",          name: "home",      component: () => import("@/pages/Index.vue"),     meta: { title: "Home" } },
+      { path: "auth",      name: "auth",      component: () => import("@/pages/Auth.vue"),      meta: { title: "Authentication" } },
+      { path: "orders",    name: "orders",    component: () => import("@/pages/Orders.vue"),    meta: { title: "My Orders" } },
+      { path: "portfolio", name: "portfolio", component: () => import("@/pages/Portfolio.vue"), meta: { title: "Portfolio" } },
+      { path: "admin",     name: "admin",     component: () => import("@/pages/Admin.vue"),     meta: { title: "Admin Panel" } },
+      { path: "privacy",   name: "privacy",   component: () => import("@/pages/Privacy.vue"),   meta: { title: "Privacy Policy" } },
+      { path: "about",     name: "about",     component: () => import("@/pages/About.vue"),     meta: { title: "About Us" } },
+      { path: "careers",   name: "careers",   component: () => import("@/pages/Careers.vue"),   meta: { title: "Careers" } },
+      { path: "blog",      name: "blog",      component: () => import("@/pages/Blog.vue"),      meta: { title: "Blog" } },
+      { path: "blog/:id",  name: "blog-post", component: () => import("@/pages/BlogPost.vue"),  meta: { title: "Blog Post" } },
+      { path: "contact",   name: "contact",   component: () => import("@/pages/Contact.vue"),   meta: { title: "Contact" } },
+      { path: "help",      name: "help",      component: () => import("@/pages/HelpCenter.vue"), meta: { title: "Help Center" } },
+      { path: "guidelines", name: "guidelines", component: () => import("@/pages/Guidelines.vue"), meta: { title: "Guidelines" } },
+      { path: "terms",     name: "terms",     component: () => import("@/pages/Terms.vue"),      meta: { title: "Terms of Service" } },
+    ]
+  },
+  { path: "/:pathMatch(.*)*", component: () => import("@/pages/NotFound.vue"), meta: { title: "Not Found" } },
+];
 
 const router = createRouter({
   history: createWebHistory(),
-  routes: [
-    { path: "/",          component: () => import("@/pages/Index.vue"),     meta: { title: "Home" } },
-    { path: "/auth",      component: () => import("@/pages/Auth.vue"),      meta: { title: "Authentication" } },
-    { path: "/orders",    component: () => import("@/pages/Orders.vue"),    meta: { title: "My Orders" } },
-    { path: "/portfolio", component: () => import("@/pages/Portfolio.vue"), meta: { title: "Portfolio" } },
-    { path: "/admin",     component: () => import("@/pages/Admin.vue"),     meta: { title: "Admin Panel" } },
-    { path: "/privacy",   component: () => import("@/pages/Privacy.vue"),   meta: { title: "Privacy Policy" } },
-    { path: "/about",     component: () => import("@/pages/About.vue"),     meta: { title: "About Us" } },
-    { path: "/careers",   component: () => import("@/pages/Careers.vue"),   meta: { title: "Careers" } },
-    { path: "/blog",      component: () => import("@/pages/Blog.vue"),      meta: { title: "Blog" } },
-    { path: "/blog/:id",  component: () => import("@/pages/BlogPost.vue"),  meta: { title: "Blog Post" } },
-    { path: "/contact",   component: () => import("@/pages/Contact.vue"),   meta: { title: "Contact" } },
-    { path: "/help",      component: () => import("@/pages/HelpCenter.vue"), meta: { title: "Help Center" } },
-    { path: "/guidelines", component: () => import("@/pages/Guidelines.vue"), meta: { title: "Guidelines" } },
-    { path: "/terms",     component: () => import("@/pages/Terms.vue"),      meta: { title: "Terms of Service" } },
-    { path: "/:pathMatch(.*)*", component: () => import("@/pages/NotFound.vue"), meta: { title: "Not Found" } },
-  ],
+  routes,
   scrollBehavior(to) {
-    if (to.hash) {
-      return { el: to.hash, behavior: "smooth" };
-    }
+    if (to.hash) return { el: to.hash, behavior: "smooth" };
     return { top: 0 };
   },
 });
@@ -31,17 +41,33 @@ router.beforeEach(async (to) => {
   const { useAuthStore } = await import("@/stores/auth");
   const authStore = useAuthStore();
   
-  // Ensure we check auth state
+  // 1. Handle Language Prefix
+  let lang = to.params.lang as string;
+  
+  if (!lang) {
+    const savedLang = localStorage.getItem('locale') || 'en';
+    // Redirect /orders to /en/orders
+    return { path: `/${savedLang}${to.fullPath}` };
+  }
+
+  // 2. Sync i18n
+  if (supportedLangs.includes(lang) && i18n.global.locale.value !== lang) {
+    await setLanguage(lang);
+  }
+
+  // 3. Auth Guards
   if (!authStore.user && localStorage.getItem("token")) {
     await authStore.init();
   }
 
-  if (to.path === "/admin" && authStore.user?.role !== "admin") {
-    return { path: "/auth" };
+  const pathWithoutLang = to.path.replace(`/${lang}`, '') || '/';
+
+  if (pathWithoutLang === "/admin" && authStore.user?.role !== "admin") {
+    return { name: "auth", params: { lang } };
   }
   
-  if (to.path === "/orders" && !authStore.user) {
-    return { path: "/auth" };
+  if (pathWithoutLang === "/orders" && !authStore.user) {
+    return { name: "auth", params: { lang } };
   }
 });
 

+ 12 - 0
src/stores/auth.ts

@@ -85,9 +85,21 @@ export const useAuthStore = defineStore("auth", () => {
             playNotificationSound();
           }
           unreadMessagesCount.value = msg.count;
+        } else if (msg.type === "new_chat_message") {
+          toast(`Message for Order #${msg.order_id}`, {
+            description: msg.text,
+            action: {
+              label: "View",
+              onClick: () => {
+                window.location.href = `/admin?order=${msg.order_id}`;
+              }
+            }
+          });
         } else if (msg.type === "account_suspended") {
           toast.error("Your account has been suspended by an administrator.", { duration: 10000 });
           logout();
+        } else if (msg.type === "order_read") {
+          window.dispatchEvent(new CustomEvent("radionica:order_read", { detail: { order_id: msg.order_id } }));
         }
       } catch (e) {
         console.error("WS Parse error", e);

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff