Initial Commit

This commit is contained in:
Abdul Aziz Amrullah 2026-05-18 10:39:40 +07:00
commit b84cee0a9e
9 changed files with 1143 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.venv

90
README.md Normal file
View File

@ -0,0 +1,90 @@
# Tada to Odoo 19 Migration Portal
A lightweight, modern web application built with Python (Flask) that streamlines the process of migrating member/customer data from a PostgreSQL database (`tada_member`) directly into an Odoo 19 instance via XML-RPC.
## 🚀 Features
- **Direct Odoo Integration:** Seamlessly creates `res.partner` and `loyalty.card` records in Odoo 19.
- **Data Mapping Automation:** Automatically maps database fields (including custom fields like `gender`, `birth_date`, and `total_spend`) and dynamically links the appropriate `loyalty.program` based on the user's membership level.
- **High-Performance DataTables:** Uses Server-Side Processing for pagination, sorting, and debounced searching. This ensures the UI remains lightning-fast, even with hundreds of thousands of records.
- **Beautiful & Modern UI:** Features a clean, responsive "Glassmorphism" light theme optimized for both Desktop and Mobile environments.
- **Secure Sessions:** Employee logins are secured with an 8-hour session lifetime mechanism.
- **Interactive Prompts:** Integrated with SweetAlert2 to prevent accidental migrations and to provide clean success/error feedback.
## 🛠️ Tech Stack
- **Backend:** Python 3, Flask, Flask-Session
- **Database Connection:** `psycopg2-binary` (PostgreSQL)
- **Odoo Integration:** `xmlrpc.client` (Odoo XML-RPC API)
- **Frontend:** HTML5, CSS3, Vanilla JavaScript, jQuery, DataTables, SweetAlert2
## 📦 Installation & Setup
1. **Clone or Extract the Repository**
Ensure you are in the project root directory.
2. **Create a Virtual Environment (Optional but Recommended)**
```bash
python -m venv venv
# On Windows:
venv\Scripts\activate
# On macOS/Linux:
source venv/bin/activate
```
3. **Install Dependencies**
Install the required Python packages using pip:
```bash
pip install -r requirements.txt
```
4. **Configure Credentials**
Open `app.py` in your text editor and locate the **Configurations** section at the top of the file. Update the placeholders with your actual PostgreSQL and Odoo 19 credentials:
```python
# PostgreSQL
DB_HOST = "localhost"
DB_PORT = "5432"
DB_NAME = "your_postgres_db"
DB_USER = "your_postgres_user"
DB_PASS = "your_postgres_password"
# Odoo 19
ODOO_URL = "localhost"
ODOO_DB = "odoo19"
ODOO_USER = "admin"
ODOO_PASS = "admin"
```
5. **Run the Application**
```bash
python app.py
```
The application will start a local development server. Open your web browser and navigate to: `http://localhost:5000`
## 🔄 Data Mapping Reference
When a migration is triggered, the following mapping occurs:
### 1. `res.partner` (Customer Profile)
| PostgreSQL (`tada_member`) | Odoo 19 (`res.partner`) | Notes |
| :--- | :--- | :--- |
| `name` | `name` | |
| `phone_number` | `phone` | |
| `email` | `email` | |
| `gender` | `gender` | Capitalized automatically (e.g., `female` -> `Female`) |
| `birthday` | `birth_date` | Converted to string format |
| `city` | `city` | |
| `total_spending` | `total_spend` | Converted to Float |
| `level` | `membership_level_id` | Linked via `loyalty.program` search (Many2one) |
### 2. `loyalty.card` (Membership Card)
| PostgreSQL (`tada_member`) | Odoo 19 (`loyalty.card`) | Notes |
| :--- | :--- | :--- |
| `point_amount` | `points` | Converted to Float |
| (Generated) | `partner_id` | Linked to the newly created `res.partner` ID |
| (Generated) | `program_id` | Linked to the matched `loyalty.program` ID |
## 🛡️ Important Notes
- **Data Validation:** The script automatically handles `NULL`/`None` values from PostgreSQL, safely converting them to `False` to prevent XML-RPC marshal errors when writing to Odoo.
- **Debounce Optimization:** The search bar waits 400ms after you stop typing and requires a minimum of 3 characters before querying the database, drastically reducing server load.

212
app.py Normal file
View File

@ -0,0 +1,212 @@
import xmlrpc.client
import psycopg2
from psycopg2.extras import RealDictCursor
from flask import Flask, render_template, request, session, redirect, url_for, jsonify
from datetime import datetime, timedelta
app = Flask(__name__)
# Secret key for sessions
app.secret_key = 'super_secret_tada_migration_key_change_me_in_prod'
app.permanent_session_lifetime = timedelta(hours=8)
# --- Configurations ---
# PostgreSQL (Hardcoded as requested)
DB_HOST = "192.169.0.10"
DB_PORT = "5432"
DB_NAME = "postgres"
DB_USER = "postgres"
DB_PASS = "Mulut!23Berkomunikasi"
# Odoo 19
ODOO_URL = "http://localhost:1900"
ODOO_DB = "odoo19_OT_v3"
ODOO_USER = "admin"
ODOO_PASS = "admin"
def get_db_connection():
conn = psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
dbname=DB_NAME,
user=DB_USER,
password=DB_PASS
)
return conn
@app.route('/')
def index():
if 'employee_id' not in session:
return redirect(url_for('login'))
return render_template('index.html', employee_id=session['employee_id'])
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
employee_id = request.form.get('employee_id')
if employee_id:
session.permanent = True
session['employee_id'] = employee_id
return redirect(url_for('index'))
return render_template('login.html')
@app.route('/logout')
def logout():
session.pop('employee_id', None)
return redirect(url_for('login'))
@app.route('/api/customers')
def get_customers():
if 'employee_id' not in session:
return jsonify({"error": "Unauthorized"}), 401
# DataTables parameters
draw = request.args.get('draw', type=int, default=1)
start = request.args.get('start', type=int, default=0)
length = request.args.get('length', type=int, default=10)
search_value = request.args.get('search[value]', default="")
order_column_index = request.args.get('order[0][column]', type=int, default=0)
order_dir = request.args.get('order[0][dir]', default='asc')
columns_map = ['id', 'name', 'phone_number', 'email', 'level', 'point_amount', 'responsible', 'total_spending']
order_column = columns_map[order_column_index] if order_column_index < len(columns_map) else 'id'
if order_dir not in ['asc', 'desc']:
order_dir = 'asc'
conn = None
try:
conn = get_db_connection()
cursor = conn.cursor(cursor_factory=RealDictCursor)
# Base query
query = "SELECT * FROM tada_member"
count_query = "SELECT COUNT(*) FROM tada_member"
params = []
if search_value:
search_pattern = f"%{search_value}%"
where_clause = " WHERE name ILIKE %s OR phone_number ILIKE %s OR email ILIKE %s"
query += where_clause
count_query += where_clause
params.extend([search_pattern, search_pattern, search_pattern])
# Get total filtered count
cursor.execute(count_query, params)
total_filtered = cursor.fetchone()['count']
# Total records without filter
cursor.execute("SELECT COUNT(*) FROM tada_member")
total_records = cursor.fetchone()['count']
# Add pagination and sorting
query += f" ORDER BY {order_column} {order_dir.upper()} LIMIT %s OFFSET %s"
params.extend([length, start])
cursor.execute(query, params)
customers = cursor.fetchall()
# Convert date fields to string if any to allow JSON serialization
for row in customers:
if row.get('birthday'):
row['birthday'] = str(row['birthday'])
if row.get('updated_at'):
row['updated_at'] = str(row['updated_at'])
return jsonify({
"draw": draw,
"recordsTotal": total_records,
"recordsFiltered": total_filtered,
"data": customers
})
except Exception as e:
print(f"Error fetching customers: {e}")
return jsonify({"error": str(e)}), 500
finally:
if conn:
conn.close()
@app.route('/api/migrate/<int:customer_id>', methods=['POST'])
def migrate_customer(customer_id):
if 'employee_id' not in session:
return jsonify({"success": False, "message": "Unauthorized"}), 401
employee_id = session['employee_id']
conn = None
try:
# Fetch customer data
conn = get_db_connection()
cursor = conn.cursor(cursor_factory=RealDictCursor)
cursor.execute("SELECT * FROM tada_member WHERE id = %s", (customer_id,))
customer = cursor.fetchone()
if not customer:
return jsonify({"success": False, "message": "Customer not found."}), 404
# Odoo connection
common = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/common')
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASS, {})
if not uid:
return jsonify({"success": False, "message": "Failed to authenticate with Odoo."}), 500
models = xmlrpc.client.ServerProxy(f'{ODOO_URL}/xmlrpc/2/object')
# 1. Find loyalty.program based on level
level = customer.get('level', '')
program_ids = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'loyalty.program', 'search', [[('name', 'ilike', level)]])
if not program_ids:
return jsonify({"success": False, "message": f"Loyalty program containing '{level}' not found in Odoo."}), 404
program_id = program_ids[0]
# Map gender safely (e.g. 'female' -> 'Female')
gender_val = customer.get('gender')
if isinstance(gender_val, str) and gender_val:
gender_val = gender_val.title()
# 2. Create res.partner
partner_data = {
'name': customer.get('name', ''),
'phone': customer.get('phone_number', ''),
'email': customer.get('email', ''),
'gender': gender_val or '',
'birth_date': str(customer.get('birthday', '')) if customer.get('birthday') else False,
'city': customer.get('city', ''),
'membership_level_id': program_id,
'total_spend': float(customer.get('total_spending') or 0.0)
}
# Replace None with False for Odoo XML-RPC compatibility
partner_data = {k: (v if v is not None else False) for k, v in partner_data.items()}
partner_id = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'res.partner', 'create', [partner_data])
# 3. Create loyalty.card
point_amount = customer.get('point_amount')
card_data = {
'partner_id': partner_id,
'program_id': program_id,
'points': float(point_amount) if point_amount is not None else 0.0
}
card_id = models.execute_kw(ODOO_DB, uid, ODOO_PASS, 'loyalty.card', 'create', [card_data])
# 4. Update tada_member
now = datetime.now()
cursor.execute("UPDATE tada_member SET responsible = %s, updated_at = %s WHERE id = %s", (employee_id, now, customer_id))
conn.commit()
return jsonify({"success": True, "message": "Migrasi data berhasil!"})
except Exception as e:
if conn:
conn.rollback()
print(f"Migration Error: {e}")
return jsonify({"success": False, "message": str(e)}), 500
finally:
if conn:
conn.close()
if __name__ == '__main__':
app.run(debug=True, port=5000, host='0.0.0.0')

161
import.py Normal file
View File

@ -0,0 +1,161 @@
import pandas as pd
import re
from sqlalchemy import create_engine
import concurrent.futures
# ==========================================
# KONFIGURASI DATABASE & FILE
# ==========================================
DB_USER = 'postgres'
DB_PASSWORD = 'Mulut!23Berkomunikasi'
DB_HOST = '192.169.0.10' # atau IP server DB Anda
DB_PORT = '5432'
DB_NAME = 'postgres'
TABLE_NAME = 'tada_member'
FILE_EXCEL = 'C:/Users/abdul.aziz/Downloads/customer-data-rawformat-2604230703365650-1.xlsx'
# ==========================================
# KONFIGURASI MULTI-THREADING
# ==========================================
MAX_THREADS = 16 # Atur jumlah thread secara dinamis (misal: 4, 8, atau 16)
CHUNK_SIZE = 1000 # Jumlah baris data yang diinsert per thread/proses
# ==========================================
# MAPPING KOLOM (SANGAT PENTING)
# ==========================================
# Sesuaikan key (kiri) dengan nama kolom di EXCEL.
# Sesuaikan value (kanan) dengan nama kolom di POSTGRESQL (berdasarkan screenshot Anda).
COLUMN_MAPPING = {
'Card Number': 'card_number',
'Name': 'name',
'Phone Number': 'phone_number',
'Email': 'email',
'Gender': 'gender',
'Birthday': 'birthday',
'City': 'city',
'Card Status': 'card_status',
'Level': 'level',
'Wallet Info': 'point_amount', # Ini nanti akan berisi angka yang sudah di-ekstrak
'Total Spending (IDR)': 'total_spending',
}
def extract_wallet_amount(text):
"""
Fungsi untuk mengekstrak angka amount dari teks Wallet Info.
Target: "[wallet name: Poin, amount: 127600, ..." -> 127600
"""
if pd.isna(text):
return None
# Regex untuk mengambil angka setelah "[wallet name: Poin, amount: " dan sebelum ","
# \s* mengatasi spasi yang mungkin tidak konsisten
match = re.search(r"\[wallet name:\s*Poin,\s*amount:\s*([^,]+)", str(text))
if match:
try:
return float(match.group(1).strip())
except ValueError:
return None
return None
def clean_phone_number(text):
"""
Membersihkan akhiran .0 pada nomor HP jika terbaca sebagai float oleh pandas.
"""
if pd.isna(text):
return None
# Ubah menjadi string dan hapus spasi berlebih
phone_str = str(text).strip()
# Jika berakhiran .0, potong 2 karakter terakhir
if phone_str.endswith('.0'):
phone_str = phone_str[:-2]
return phone_str
def insert_data_chunk(chunk, engine, table_name, chunk_id):
"""
Fungsi worker untuk thread: memasukkan potongan data (chunk) ke database.
"""
try:
# if_exists='append' untuk menambah data ke tabel yang sudah ada
chunk.to_sql(table_name, engine, if_exists='append', index=False)
print(f"✅ Chunk {chunk_id} berhasil di-insert ({len(chunk)} baris).")
return True
except Exception as e:
print(f"❌ Error pada Chunk {chunk_id}: {e}")
return False
def main():
# 1. Dapatkan daftar kolom langsung dari mapping (tanpa perlu baca warna background)
target_cols = list(COLUMN_MAPPING.keys())
# 2. Baca semua sheet dari Excel
print("Membaca semua sheet dari file Excel...")
# sheet_name=None akan membaca semua sheet menjadi dictionary {nama_sheet: dataframe}
all_sheets_dict = pd.read_excel(FILE_EXCEL, sheet_name=None)
# 3. Gabungkan semua sheet menjadi satu DataFrame
df_combined = pd.concat(all_sheets_dict.values(), ignore_index=True)
print(f"Total data tergabung dari semua sheet: {len(df_combined)} baris.")
# 4. Filter kolom: Hanya ambil kolom yang kita butuhkan (sesuai keys di mapping)
available_target_cols = [col for col in target_cols if col in df_combined.columns]
df_filtered = df_combined[available_target_cols].copy()
# 5. Proses Kolom 'Wallet Info' dan 'Phone Number'
if 'Wallet Info' in df_filtered.columns:
print("Mengekstrak data pada kolom Wallet Info...")
df_filtered['Wallet Info'] = df_filtered['Wallet Info'].apply(extract_wallet_amount)
if 'Phone Number' in df_filtered.columns:
print("Membersihkan format nomor HP...")
df_filtered['Phone Number'] = df_filtered['Phone Number'].apply(clean_phone_number)
# 6. Rename nama kolom Excel agar sesuai dengan nama kolom PostgreSQL
# Hanya rename kolom yang ada di COLUMN_MAPPING
print("Menyesuaikan nama kolom dengan tabel PostgreSQL...")
df_final = df_filtered.rename(columns=COLUMN_MAPPING)
# Pastikan kita hanya mengimport kolom yang sudah di-mapping ke DB
# Jika ada kolom kuning yang tidak ada di mapping, kita drop agar DB tidak error
db_columns = list(COLUMN_MAPPING.values())
cols_to_import = [col for col in df_final.columns if col in db_columns]
df_final = df_final[cols_to_import]
# 7. Insert ke PostgreSQL menggunakan Multi-Threading
print(f"\nMenyiapkan proses insert dengan {MAX_THREADS} thread...")
try:
# Format koneksi SQLAlchemy
# (tambahkan parameter pool_size agar aman untuk banyak thread yang buka koneksi bersamaan)
engine_url = f'postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
engine = create_engine(engine_url, pool_size=MAX_THREADS, max_overflow=5)
# Memecah dataframe menjadi beberapa chunk (potongan kecil)
chunks = [df_final[i:i + CHUNK_SIZE] for i in range(0, df_final.shape[0], CHUNK_SIZE)]
total_chunks = len(chunks)
print(f"Data sebanyak {len(df_final)} baris dipecah menjadi {total_chunks} chunk.")
# Menjalankan multi-threading menggunakan ThreadPoolExecutor
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_THREADS) as executor:
# Submit semua task (chunk) ke executor
futures = {
executor.submit(insert_data_chunk, chunk, engine, TABLE_NAME, i+1): i+1
for i, chunk in enumerate(chunks)
}
# Menunggu dan memantau hasil dari setiap thread yang selesai
berhasil = 0
for future in concurrent.futures.as_completed(futures):
if future.result():
berhasil += 1
print(f"\n🎉 Selesai! {berhasil} dari {total_chunks} chunk berhasil di-import ke PostgreSQL.")
except Exception as e:
print(f"❌ Terjadi kesalahan fatal saat setup database: {e}")
if __name__ == "__main__":
main()

7
requirements.txt Normal file
View File

@ -0,0 +1,7 @@
Flask==3.0.0
Flask-Session==0.5.0
psycopg2-binary==2.9.9
Werkzeug==3.0.1
pandas
sqlalchemy
openpyxl

436
static/style.css Normal file
View File

@ -0,0 +1,436 @@
:root {
--bg-color: #f8fafc;
--text-color: #0f172a;
--text-muted: #64748b;
--glass-bg: rgba(255, 255, 255, 0.85);
--glass-border: rgba(255, 255, 255, 0.5);
--glass-shadow: rgba(0, 0, 0, 0.05);
--primary: #4f46e5;
--primary-hover: #4338ca;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-background {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: -1;
overflow: hidden;
background: radial-gradient(circle at top left, #e0e7ff, #f8fafc);
}
.blob {
position: absolute;
filter: blur(80px);
opacity: 0.6;
border-radius: 50%;
animation: float 10s infinite ease-in-out alternate;
}
.blob-1 {
top: -10%;
left: -10%;
width: 400px;
height: 400px;
background: #c7d2fe;
}
.blob-2 {
bottom: -10%;
right: -10%;
width: 500px;
height: 500px;
background: #fbcfe8;
animation-delay: -5s;
}
@keyframes float {
0% { transform: translate(0, 0) scale(1); }
100% { transform: translate(50px, 50px) scale(1.1); }
}
/* Glass Panels */
.glass-panel {
background: var(--glass-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: 16px;
box-shadow: 0 4px 30px var(--glass-shadow);
}
.glass-nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(0,0,0,0.05);
position: sticky;
top: 0;
z-index: 100;
}
/* Navigation */
.nav-brand {
font-size: 1.25rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--primary);
}
.nav-user {
display: flex;
align-items: center;
gap: 1.5rem;
}
.user-badge {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(0,0,0,0.04);
padding: 0.5rem 1rem;
border-radius: 99px;
font-size: 0.875rem;
font-weight: 500;
}
.btn-logout {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--danger);
text-decoration: none;
font-weight: 500;
font-size: 0.875rem;
transition: all 0.2s;
}
.btn-logout:hover {
color: #b91c1c;
}
/* Content */
.app-container {
flex: 1;
padding: 2rem;
display: flex;
flex-direction: column;
}
.content-wrapper {
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.panel-header {
padding: 1.5rem 2rem;
border-bottom: 1px solid rgba(0,0,0,0.05);
}
.panel-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-color);
}
.panel-subtitle {
margin: 0.5rem 0 0 0;
color: var(--text-muted);
font-size: 0.875rem;
}
/* Table Area */
.table-panel {
margin-top: 1rem;
}
.table-responsive {
padding: 1.5rem 2rem;
overflow-x: auto;
}
/* DataTables customization for Light Mode */
.dataTables_wrapper .dataTables_length,
.dataTables_wrapper .dataTables_filter,
.dataTables_wrapper .dataTables_info,
.dataTables_wrapper .dataTables_processing,
.dataTables_wrapper .dataTables_paginate {
color: var(--text-muted) !important;
margin-bottom: 1rem;
}
.dataTables_wrapper .dataTables_filter input {
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 8px;
color: var(--text-color);
padding: 0.5rem 1rem;
outline: none;
margin-left: 0.5rem;
transition: border-color 0.2s;
}
.dataTables_wrapper .dataTables_filter input:focus {
border-color: var(--primary);
}
.dataTables_wrapper .dataTables_length select {
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 6px;
color: var(--text-color);
padding: 0.25rem;
}
table.dataTable {
border-collapse: collapse !important;
width: 100% !important;
color: var(--text-color);
border-bottom: none !important;
}
table.dataTable thead th {
border-bottom: 1px solid #e2e8f0 !important;
color: var(--text-muted);
font-weight: 600;
padding: 1rem 0.5rem !important;
text-align: left;
}
table.dataTable tbody tr {
background-color: transparent !important;
transition: background-color 0.2s;
}
table.dataTable tbody tr:hover {
background-color: rgba(0,0,0,0.02) !important;
}
table.dataTable tbody td {
border-bottom: 1px solid #f1f5f9 !important;
padding: 1rem 0.5rem !important;
vertical-align: middle;
}
/* Pagination */
.dataTables_wrapper .dataTables_paginate .paginate_button {
color: var(--text-muted) !important;
border: 1px solid transparent !important;
border-radius: 8px !important;
}
.dataTables_wrapper .dataTables_paginate .paginate_button.current,
.dataTables_wrapper .dataTables_paginate .paginate_button.current:hover {
background: var(--primary) !important;
color: white !important;
border: 1px solid var(--primary) !important;
}
.dataTables_wrapper .dataTables_paginate .paginate_button:hover {
background: rgba(0,0,0,0.04) !important;
color: var(--text-color) !important;
border: 1px solid transparent !important;
}
/* Buttons and Badges */
.btn-migrasi {
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
font-family: inherit;
font-size: 0.875rem;
}
.btn-migrasi:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
}
.btn-migrasi.disabled {
background: #e2e8f0;
color: #94a3b8;
cursor: not-allowed;
box-shadow: none;
}
.badge {
padding: 0.25rem 0.75rem;
border-radius: 99px;
font-size: 0.75rem;
font-weight: 600;
display: inline-block;
}
.badge-success {
background: #d1fae5;
color: #047857;
border: 1px solid #a7f3d0;
}
.badge-warning {
background: #fef3c7;
color: #b45309;
border: 1px solid #fde68a;
}
/* Login Page */
.login-wrapper {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}
.login-panel {
width: 100%;
max-width: 400px;
padding: 3rem 2rem;
text-align: center;
}
.brand-logo {
color: var(--primary);
margin-bottom: 1.5rem;
}
.login-panel .title {
margin: 0 0 0.5rem 0;
font-size: 1.75rem;
color: var(--text-color);
}
.login-panel .subtitle {
color: var(--text-muted);
margin: 0 0 2rem 0;
font-size: 0.875rem;
}
.input-group {
text-align: left;
margin-bottom: 1.5rem;
}
.input-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: var(--text-color);
font-weight: 500;
}
.input-group input {
width: 100%;
background: #ffffff;
border: 1px solid #cbd5e1;
padding: 0.75rem 1rem;
border-radius: 8px;
color: var(--text-color);
font-family: inherit;
font-size: 1rem;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2);
}
.btn-primary {
width: 100%;
background: var(--primary);
color: white;
border: none;
padding: 0.875rem;
border-radius: 8px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
font-family: inherit;
transition: background-color 0.2s;
}
.btn-primary:hover {
background: var(--primary-hover);
}
/* Responsive Adjustments for HP/Mobile */
@media (max-width: 768px) {
.glass-nav {
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.nav-user {
width: 100%;
justify-content: space-between;
}
.app-container {
padding: 1rem;
}
.panel-header {
padding: 1rem;
}
.panel-title {
font-size: 1.25rem;
}
.table-responsive {
padding: 1rem;
}
.dataTables_wrapper .dataTables_filter {
text-align: left !important;
margin-top: 1rem;
float: none;
}
.dataTables_wrapper .dataTables_filter input {
width: 100%;
margin-left: 0;
margin-top: 0.5rem;
box-sizing: border-box;
}
.dataTables_wrapper .dataTables_length {
float: none;
text-align: left;
}
.btn-migrasi {
padding: 0.4rem 0.5rem;
font-size: 0.75rem;
white-space: nowrap;
}
}

181
templates/index.html Normal file
View File

@ -0,0 +1,181 @@
{% extends 'layout.html' %}
{% block content %}
<nav class="glass-nav">
<div class="nav-brand">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>
Tada to Odoo Migration
</div>
<div class="nav-user">
<span class="user-badge">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
{{ employee_id }}
</span>
<a href="{{ url_for('logout') }}" class="btn-logout">
Log out
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
</a>
</div>
</nav>
<div class="content-wrapper">
<div class="glass-panel table-panel">
<div class="panel-header">
<h2 class="panel-title">Customer Database</h2>
<p class="panel-subtitle">Review and migrate customer profiles to Odoo 19.</p>
</div>
<div class="table-responsive">
<table id="customersTable" class="display" style="width:100%">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Phone</th>
<th>Email</th>
<th>Level</th>
<th>Points</th>
<th>Migrated By</th>
<th class="action-col">Action</th>
</tr>
</thead>
<tbody>
<!-- Populated by DataTables via AJAX -->
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
$(document).ready(function() {
var table = $('#customersTable').DataTable({
"processing": true,
"serverSide": true,
"ajax": {
"url": "{{ url_for('get_customers') }}",
"type": "GET"
},
"columns": [
{ "data": "id" },
{ "data": "name" },
{ "data": "phone_number", "defaultContent": "-" },
{ "data": "email", "defaultContent": "-" },
{ "data": "level", "defaultContent": "-" },
{ "data": "point_amount", "defaultContent": "0" },
{
"data": "responsible",
"render": function(data, type, row) {
if(data) {
return `<span class="badge badge-success">Done by ${data}</span>`;
}
return `<span class="badge badge-warning">Pending</span>`;
}
},
{
"data": null,
"orderable": false,
"render": function(data, type, row) {
if(row.responsible) {
return `<button class="btn-migrasi disabled" disabled>Migrated</button>`;
}
let safeName = row.name ? row.name.replace(/'/g, "\\'") : 'Unknown Customer';
return `<button class="btn-migrasi" onclick="migrateCustomer(${row.id}, '${safeName}')">Migrasi Data</button>`;
}
}
],
"pageLength": 10,
"language": {
"search": "Search records:",
"searchPlaceholder": "Name, Email, Phone (min 3 chars)"
}
});
// Custom search logic: debounce 400ms and min 3 characters
var searchTimeout = null;
$('.dataTables_filter input').unbind().bind('input', function() {
var searchTerm = this.value;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(function() {
if (searchTerm.length >= 3 || searchTerm.length === 0) {
table.search(searchTerm).draw();
}
}, 400);
});
window.migrateCustomer = function(id, name) {
Swal.fire({
title: 'Confirm Migration',
text: `Are you sure you want to migrate "${name}" to Odoo 19?`,
icon: 'question',
showCancelButton: true,
confirmButtonColor: '#6366f1',
cancelButtonColor: '#334155',
confirmButtonText: 'Yes, migrate now!',
background: '#1e293b',
color: '#f8fafc',
customClass: {
popup: 'glass-popup'
}
}).then((result) => {
if (result.isConfirmed) {
// Show loading
Swal.fire({
title: 'Migrating...',
text: 'Please wait while data is being transferred.',
allowOutsideClick: false,
background: '#1e293b',
color: '#f8fafc',
didOpen: () => {
Swal.showLoading();
}
});
// AJAX Request
$.ajax({
url: `/api/migrate/${id}`,
type: 'POST',
success: function(response) {
if(response.success) {
Swal.fire({
title: 'Success!',
text: response.message,
icon: 'success',
background: '#1e293b',
color: '#f8fafc',
confirmButtonColor: '#10b981'
});
table.draw(false); // Reload table data without resetting pagination
} else {
Swal.fire({
title: 'Failed!',
text: response.message,
icon: 'error',
background: '#1e293b',
color: '#f8fafc',
confirmButtonColor: '#ef4444'
});
}
},
error: function(xhr) {
let msg = "An unexpected error occurred.";
if(xhr.responseJSON && xhr.responseJSON.message) {
msg = xhr.responseJSON.message;
}
Swal.fire({
title: 'Error!',
text: msg,
icon: 'error',
background: '#1e293b',
color: '#f8fafc',
confirmButtonColor: '#ef4444'
});
}
});
}
});
}
});
</script>
{% endblock %}

34
templates/layout.html Normal file
View File

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tada Migration Portal</title>
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- DataTables -->
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css">
<!-- SweetAlert2 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.css">
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block extra_head %}{% endblock %}
</head>
<body>
<div class="app-background">
<!-- Animated background elements can go here via CSS -->
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
</div>
<div class="app-container">
{% block content %}{% endblock %}
</div>
<!-- Scripts -->
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

21
templates/login.html Normal file
View File

@ -0,0 +1,21 @@
{% extends 'layout.html' %}
{% block content %}
<div class="login-wrapper">
<div class="glass-panel login-panel">
<div class="brand-logo">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>
</div>
<h1 class="title">Tada Migration</h1>
<p class="subtitle">Enter your catapa ID to securely access the migration portal.</p>
<form action="{{ url_for('login') }}" method="POST" class="login-form">
<div class="input-group">
<label for="employee_id">Catapa ID</label>
<input type="text" id="employee_id" name="employee_id" placeholder="14045" required autofocus>
</div>
<button type="submit" class="btn-primary">Login</button>
</form>
</div>
</div>
{% endblock %}