From b84cee0a9e0736b9f6d001d7c88d81d854bdba4e Mon Sep 17 00:00:00 2001 From: Abdul Aziz Amrullah Date: Mon, 18 May 2026 10:39:40 +0700 Subject: [PATCH] Initial Commit --- .gitignore | 1 + README.md | 90 +++++++++ app.py | 212 ++++++++++++++++++++ import.py | 161 ++++++++++++++++ requirements.txt | 7 + static/style.css | 436 ++++++++++++++++++++++++++++++++++++++++++ templates/index.html | 181 ++++++++++++++++++ templates/layout.html | 34 ++++ templates/login.html | 21 ++ 9 files changed, 1143 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 import.py create mode 100644 requirements.txt create mode 100644 static/style.css create mode 100644 templates/index.html create mode 100644 templates/layout.html create mode 100644 templates/login.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b694934 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.venv \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..58e3170 --- /dev/null +++ b/README.md @@ -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. diff --git a/app.py b/app.py new file mode 100644 index 0000000..6ac7494 --- /dev/null +++ b/app.py @@ -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/', 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') diff --git a/import.py b/import.py new file mode 100644 index 0000000..af22c29 --- /dev/null +++ b/import.py @@ -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() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3be58eb --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..a3d0780 --- /dev/null +++ b/static/style.css @@ -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; + } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..49e8db3 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,181 @@ +{% extends 'layout.html' %} + +{% block content %} + + +
+
+
+

Customer Database

+

Review and migrate customer profiles to Odoo 19.

+
+
+ + + + + + + + + + + + + + + + +
IDNamePhoneEmailLevelPointsMigrated ByAction
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..c2dea2a --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,34 @@ + + + + + + Tada Migration Portal + + + + + + + + + {% block extra_head %}{% endblock %} + + +
+ +
+
+
+ +
+ {% block content %}{% endblock %} +
+ + + + + + {% block extra_scripts %}{% endblock %} + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..d8785f1 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,21 @@ +{% extends 'layout.html' %} + +{% block content %} + +{% endblock %}