Full technology change: Next.js App Router replaced with Vite + react-router-dom v6. All 21 pages and 5 landing components converted (next/link -> Link, next/navigation -> react-router-dom hooks, next/font -> @fontsource/inter, process.env -> import.meta.env). Dropped the unused Stripe API routes, which can't run in a client-only Vite build anyway and were never wired to any page. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
174 lines
7.3 KiB
TypeScript
174 lines
7.3 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { api, session } from '@/lib/api';
|
|
import { Plus, Trash2, TestTube2, Globe, CheckCircle } from 'lucide-react';
|
|
|
|
interface Instance {
|
|
instance_name: string;
|
|
url: string;
|
|
database: string;
|
|
username: string;
|
|
type: string;
|
|
}
|
|
|
|
export default function Connections() {
|
|
const [instances, setInstances] = useState<Instance[]>([]);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [form, setForm] = useState({
|
|
instance_name: 'default',
|
|
url: '',
|
|
database: '',
|
|
username: '',
|
|
credential: '',
|
|
is_api_key: true,
|
|
});
|
|
|
|
function load() {
|
|
const key = session.getKey();
|
|
if (!key) return;
|
|
api.listCredentials(key).then((r) => setInstances(r.instances)).catch(console.error);
|
|
}
|
|
useEffect(load, []);
|
|
|
|
async function handleAdd(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
const key = session.getKey();
|
|
if (!key) return;
|
|
setLoading(true);
|
|
try {
|
|
await api.addCredential(key, form);
|
|
setShowForm(false);
|
|
setForm({ instance_name: 'default', url: '', database: '', username: '', credential: '', is_api_key: true });
|
|
load();
|
|
} catch (err) {
|
|
alert(err instanceof Error ? err.message : 'Failed to add connection');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleDelete(name: string) {
|
|
if (!confirm(`Remove "${name}"?`)) return;
|
|
const key = session.getKey();
|
|
if (!key) return;
|
|
await api.deleteCredential(key, name);
|
|
load();
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-4xl space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-gray-900">Odoo Connections</h1>
|
|
<p className="text-sm text-gray-500 mt-0.5">Manage your Odoo ERP instances.</p>
|
|
</div>
|
|
<button onClick={() => setShowForm(true)}
|
|
className="flex items-center gap-2 gradient-brand text-white px-4 py-2 rounded-xl text-sm font-medium hover:opacity-90 transition-opacity">
|
|
<Plus size={16} /> Add Connection
|
|
</button>
|
|
</div>
|
|
|
|
{/* Existing connections */}
|
|
{instances.length === 0 && !showForm && (
|
|
<div className="bg-white rounded-xl border border-dashed border-gray-300 p-12 text-center">
|
|
<Globe size={32} className="mx-auto text-gray-300 mb-3" />
|
|
<p className="text-sm font-medium text-gray-600 mb-1">No connections yet</p>
|
|
<p className="text-xs text-gray-400 mb-4">Add your Odoo instance to get started.</p>
|
|
<button onClick={() => setShowForm(true)}
|
|
className="gradient-brand text-white px-4 py-2 rounded-xl text-sm font-medium">
|
|
Add Connection
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{instances.map((inst) => (
|
|
<div key={inst.instance_name} className="bg-white rounded-xl border border-gray-100 shadow-sm p-5">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-green-100 rounded-xl flex items-center justify-center">
|
|
<CheckCircle size={18} className="text-green-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-semibold text-sm text-gray-900">{inst.instance_name}</p>
|
|
<p className="text-xs text-gray-500">{inst.url}</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={() => handleDelete(inst.instance_name)}
|
|
className="text-gray-400 hover:text-red-500 p-1 transition-colors">
|
|
<Trash2 size={15} />
|
|
</button>
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-3 gap-4 text-xs">
|
|
<div><p className="text-gray-400 mb-0.5">Database</p><p className="text-gray-700 font-mono">{inst.database}</p></div>
|
|
<div><p className="text-gray-400 mb-0.5">Username</p><p className="text-gray-700">{inst.username}</p></div>
|
|
<div><p className="text-gray-400 mb-0.5">Auth Type</p><p className="text-gray-700 capitalize">{inst.type}</p></div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Add connection form panel */}
|
|
{showForm && (
|
|
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
|
|
<h2 className="text-sm font-semibold text-gray-900 mb-5">Add Odoo Connection</h2>
|
|
<form onSubmit={handleAdd} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Field label="Connection Name" value={form.instance_name}
|
|
onChange={(v) => setForm({ ...form, instance_name: v })} placeholder="e.g. production" />
|
|
<Field label="Odoo URL" value={form.url}
|
|
onChange={(v) => setForm({ ...form, url: v })} placeholder="https://mycompany.odoo.com" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Field label="Database Name" value={form.database}
|
|
onChange={(v) => setForm({ ...form, database: v })} placeholder="mydb" />
|
|
<Field label="Odoo Username" value={form.username}
|
|
onChange={(v) => setForm({ ...form, username: v })} placeholder="admin@company.com" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">Authentication Method</label>
|
|
<select value={form.is_api_key ? 'apikey' : 'password'}
|
|
onChange={(e) => setForm({ ...form, is_api_key: e.target.value === 'apikey' })}
|
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500">
|
|
<option value="apikey">API Key (Recommended)</option>
|
|
<option value="password">Password</option>
|
|
</select>
|
|
</div>
|
|
<Field
|
|
label={form.is_api_key ? 'Odoo API Key' : 'Password'}
|
|
value={form.credential}
|
|
onChange={(v) => setForm({ ...form, credential: v })}
|
|
placeholder={form.is_api_key ? 'Paste your Odoo API key' : 'Odoo password'}
|
|
type="password"
|
|
/>
|
|
|
|
<div className="flex gap-3 pt-2">
|
|
<button type="submit" disabled={loading}
|
|
className="gradient-brand text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:opacity-90 disabled:opacity-60 flex items-center gap-2">
|
|
{loading ? 'Saving…' : <><TestTube2 size={14} /> Test & Save</>}
|
|
</button>
|
|
<button type="button" onClick={() => setShowForm(false)}
|
|
className="border border-gray-200 text-gray-600 px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-gray-50">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({ label, value, onChange, placeholder, type = 'text' }: {
|
|
label: string; value: string; onChange: (v: string) => void; placeholder?: string; type?: string;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700 mb-1.5">{label}</label>
|
|
<input
|
|
type={type} value={value} onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder} required
|
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|