460 lines
16 KiB
HTML
460 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Admin Panel</title>
|
|
<style>
|
|
body {
|
|
font-family: system-ui, sans-serif;
|
|
padding: 2rem;
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
}
|
|
h1 {
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.module {
|
|
border: 1px solid #ccc;
|
|
padding: 1rem;
|
|
border-radius: 10px;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 0.2rem 0.6rem;
|
|
background: #e5e7eb;
|
|
border-radius: 9999px;
|
|
font-size: 0.8rem;
|
|
color: #111827;
|
|
}
|
|
.field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.field input {
|
|
padding: 0.5rem;
|
|
border: 1px solid #ddd;
|
|
border-radius: 6px;
|
|
}
|
|
button {
|
|
padding: 0.5rem 0.75rem;
|
|
border: 1px solid #ccc;
|
|
border-radius: 6px;
|
|
background: #f7f7f8;
|
|
cursor: pointer;
|
|
}
|
|
button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
.error {
|
|
color: #b91c1c;
|
|
margin-top: 0.5rem;
|
|
}
|
|
.success {
|
|
color: #047857;
|
|
margin-top: 0.5rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Admin Panel</h1>
|
|
<p>Interfaz de administración (Vue 3 + JWT).</p>
|
|
|
|
<div id="app" style="display: flex; gap: 1rem;">
|
|
<div v-if="!token" class="module" style="flex: 1;">
|
|
<h2>Iniciar sesión</h2>
|
|
<div class="field">
|
|
<label>Email</label>
|
|
<input v-model="login.email" type="email" placeholder="admin@local" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Contraseña</label>
|
|
<input v-model="login.password" type="password" placeholder="••••••" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Tenant</label>
|
|
<input v-model="login.tenantId" placeholder="default" />
|
|
</div>
|
|
<button :disabled="loading" @click="loginUser">Iniciar sesión</button>
|
|
<div :class="{ error: loginError, success: loginSuccess }">{{ loginMessage }}</div>
|
|
|
|
<hr />
|
|
|
|
<h2>Registrar usuario</h2>
|
|
<div class="field">
|
|
<label>Nombre</label>
|
|
<input v-model="register.name" placeholder="Nombre (opcional)" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Email</label>
|
|
<input v-model="register.email" type="email" placeholder="admin@local" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Contraseña</label>
|
|
<input v-model="register.password" type="password" placeholder="••••••" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Tenant</label>
|
|
<input v-model="register.tenantId" placeholder="default" />
|
|
</div>
|
|
<button :disabled="loading" @click="registerUser">Registrar</button>
|
|
<div :class="{ error: registerError, success: registerSuccess }">{{ registerMessage }}</div>
|
|
</div>
|
|
|
|
<div v-else style="flex: 1; display: flex; gap: 1rem;">
|
|
<div style="width: 240px;">
|
|
<div class="module">
|
|
<div style="display:flex; align-items:center; justify-content:space-between;">
|
|
<div>
|
|
<div><strong>{{ user?.name || user?.email }}</strong></div>
|
|
<div style="font-size:0.85rem; color:#555;">Tenant: {{ tenantId }}</div>
|
|
</div>
|
|
<button @click="logout">Salir</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="module">
|
|
<h3>Módulos</h3>
|
|
<div v-for="mod in modules" :key="mod.key" style="margin-bottom:0.5rem;">
|
|
<button
|
|
:style="{
|
|
width: '100%',
|
|
textAlign: 'left',
|
|
padding: '0.5rem',
|
|
border: selectedModule === mod.key ? '1px solid #333' : '1px solid #ccc',
|
|
background: selectedModule === mod.key ? '#f0f0f0' : '#fff',
|
|
}"
|
|
@click="selectModule(mod.key)">
|
|
{{ mod.name }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="module" v-if="selectedModule">
|
|
<h3>Acciones</h3>
|
|
<div v-for="action in actions" :key="action.key" style="margin-bottom:0.4rem;">
|
|
<button style="width: 100%; text-align: left;" @click="action.handler()">{{ action.label }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="flex: 1;">
|
|
<div class="module">
|
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
|
<div>
|
|
<label>
|
|
Tenant ID:
|
|
<input v-model="tenantId" style="margin-left:0.5rem;" />
|
|
</label>
|
|
</div>
|
|
<div style="color:#555;">{{ status }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="module">
|
|
<h2 v-if="selectedModule">Administrar: {{ selectedModule }}</h2>
|
|
<div v-html="moduleHtml"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
|
<script>
|
|
const { createApp, reactive, ref, computed } = Vue;
|
|
|
|
createApp({
|
|
setup() {
|
|
const apiRoot = '/api/admin';
|
|
const authRoot = '/api/auth';
|
|
const tokenKey = 'planner_admin_token';
|
|
|
|
const token = ref(localStorage.getItem(tokenKey) || '');
|
|
const user = ref(null);
|
|
const tenantId = ref('default');
|
|
const status = ref('');
|
|
const modules = ref([]);
|
|
const selectedModule = ref('');
|
|
const moduleHtml = ref('');
|
|
const loading = ref(false);
|
|
|
|
const login = reactive({ email: '', password: '', tenantId: 'default' });
|
|
const register = reactive({ name: '', email: '', password: '', tenantId: 'default' });
|
|
|
|
const loginMessage = ref('');
|
|
const loginError = ref(false);
|
|
const loginSuccess = ref(false);
|
|
|
|
const registerMessage = ref('');
|
|
const registerError = ref(false);
|
|
const registerSuccess = ref(false);
|
|
|
|
const setStatus = (msg) => (status.value = msg);
|
|
const setToken = (t) => {
|
|
token.value = t;
|
|
if (t) localStorage.setItem(tokenKey, t);
|
|
else localStorage.removeItem(tokenKey);
|
|
};
|
|
|
|
const api = async (path, options = {}) => {
|
|
setStatus('Consultando ' + path + '...');
|
|
const headers = options.headers || {};
|
|
if (token.value) headers.Authorization = `Bearer ${token.value}`;
|
|
headers['x-tenant-id'] = tenantId.value || 'default';
|
|
const res = await fetch(`${apiRoot}${path}?tenantId=${encodeURIComponent(tenantId.value)}`, {
|
|
...options,
|
|
headers,
|
|
});
|
|
const data = await res.json().catch(() => null);
|
|
if (!res.ok) {
|
|
const err = data?.message || res.statusText;
|
|
setStatus(`Error ${res.status}`);
|
|
throw new Error(err);
|
|
}
|
|
setStatus('OK');
|
|
setTimeout(() => setStatus(''), 1500);
|
|
return data;
|
|
};
|
|
|
|
const logout = () => {
|
|
setToken('');
|
|
user.value = null;
|
|
selectedModule.value = '';
|
|
moduleHtml.value = '';
|
|
};
|
|
|
|
const loginUser = async () => {
|
|
loginMessage.value = '';
|
|
loginError.value = false;
|
|
loginSuccess.value = false;
|
|
loading.value = true;
|
|
try {
|
|
const res = await fetch(`${authRoot}/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
tenantId: login.tenantId || 'default',
|
|
email: login.email,
|
|
password: login.password,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok || !data.token) {
|
|
loginMessage.value = data.message || 'Credenciales inválidas';
|
|
loginError.value = true;
|
|
return;
|
|
}
|
|
setToken(data.token);
|
|
user.value = { email: login.email, name: data.name };
|
|
loginMessage.value = 'Sesión iniciada correctamente';
|
|
loginSuccess.value = true;
|
|
await loadModules();
|
|
} catch (err) {
|
|
loginMessage.value = err.message || err;
|
|
loginError.value = true;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const registerUser = async () => {
|
|
registerMessage.value = '';
|
|
registerError.value = false;
|
|
registerSuccess.value = false;
|
|
loading.value = true;
|
|
try {
|
|
const res = await fetch(`${authRoot}/register`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
tenantId: register.tenantId || 'default',
|
|
name: register.name,
|
|
email: register.email,
|
|
password: register.password,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
registerMessage.value = data.message || 'Error al registrar';
|
|
registerError.value = true;
|
|
return;
|
|
}
|
|
registerMessage.value = 'Registrado. Inicia sesión.';
|
|
registerSuccess.value = true;
|
|
} catch (err) {
|
|
registerMessage.value = err.message || err;
|
|
registerError.value = true;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const formatJson = (value) => `<pre style="white-space: pre-wrap; word-break: break-word;">${JSON.stringify(value, null, 2)}</pre>`;
|
|
|
|
const showResult = (value) => {
|
|
moduleHtml.value = formatJson(value);
|
|
};
|
|
|
|
const confirmPrompt = (message, defaultValue = '') => {
|
|
const result = prompt(message, defaultValue);
|
|
return result === null ? null : result.trim();
|
|
};
|
|
|
|
const listGuests = async () => {
|
|
const guests = await api('/guest');
|
|
showResult(guests);
|
|
};
|
|
|
|
const createGuest = async () => {
|
|
const name = confirmPrompt('Nombre');
|
|
if (!name) return;
|
|
const email = confirmPrompt('Email');
|
|
const phone = confirmPrompt('Teléfono');
|
|
const guest = await api('/guest', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, email, phone }),
|
|
});
|
|
showResult(guest);
|
|
};
|
|
|
|
const updateGuestRsvp = async () => {
|
|
const id = confirmPrompt('ID del invitado');
|
|
if (!id) return;
|
|
const rsvp = confirmPrompt('RSVP (true/false)', 'true');
|
|
const updated = await api(`/guest/${id}/rsvp`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ rsvp: rsvp === 'true' }),
|
|
});
|
|
showResult(updated);
|
|
};
|
|
|
|
const listTodos = async () => {
|
|
const todos = await api('/todo');
|
|
showResult(todos);
|
|
};
|
|
|
|
const createTodo = async () => {
|
|
const title = confirmPrompt('Título');
|
|
if (!title) return;
|
|
const description = confirmPrompt('Descripción');
|
|
const todo = await api('/todo', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title, description }),
|
|
});
|
|
showResult(todo);
|
|
};
|
|
|
|
const completeTodo = async () => {
|
|
const id = confirmPrompt('ID del To-do');
|
|
if (!id) return;
|
|
const completed = await api(`/todo/${id}/complete`, {
|
|
method: 'PATCH',
|
|
});
|
|
showResult(completed);
|
|
};
|
|
|
|
const listGifts = async () => {
|
|
const gifts = await api('/gift');
|
|
showResult(gifts);
|
|
};
|
|
|
|
const createGift = async () => {
|
|
const name = confirmPrompt('Nombre del regalo');
|
|
if (!name) return;
|
|
const description = confirmPrompt('Descripción');
|
|
const price = confirmPrompt('Precio');
|
|
const gift = await api('/gift', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, description, price: Number(price) || 0 }),
|
|
});
|
|
showResult(gift);
|
|
};
|
|
|
|
const contributeGift = async () => {
|
|
const id = confirmPrompt('ID del regalo');
|
|
if (!id) return;
|
|
const name = confirmPrompt('Nombre del contribuyente');
|
|
const amount = confirmPrompt('Monto');
|
|
const contribution = await api(`/gift/${id}/contribution`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ contributorName: name, amount: Number(amount) || 0 }),
|
|
});
|
|
showResult(contribution);
|
|
};
|
|
|
|
const actions = computed(() => {
|
|
const map = {
|
|
guest: [
|
|
{ key: 'list', label: 'Listar invitados', handler: listGuests },
|
|
{ key: 'create', label: 'Crear invitado', handler: createGuest },
|
|
{ key: 'rsvp', label: 'Actualizar RSVP', handler: updateGuestRsvp },
|
|
],
|
|
todo: [
|
|
{ key: 'list', label: 'Listar To-dos', handler: listTodos },
|
|
{ key: 'create', label: 'Crear To-do', handler: createTodo },
|
|
{ key: 'complete', label: 'Completar To-do', handler: completeTodo },
|
|
],
|
|
gift: [
|
|
{ key: 'list', label: 'Listar regalos', handler: listGifts },
|
|
{ key: 'create', label: 'Crear regalo', handler: createGift },
|
|
{ key: 'contribute', label: 'Agregar contribución', handler: contributeGift },
|
|
],
|
|
};
|
|
return map[selectedModule.value] || [];
|
|
});
|
|
|
|
const loadModules = async () => {
|
|
if (!token.value) return;
|
|
try {
|
|
modules.value = await api('/modules');
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const selectModule = (key) => {
|
|
selectedModule.value = key;
|
|
moduleHtml.value = `<p>Seleccionado <strong>${key}</strong>. Usa las acciones en el menú.</p>`;
|
|
};
|
|
|
|
return {
|
|
token,
|
|
user,
|
|
tenantId,
|
|
status,
|
|
modules,
|
|
selectedModule,
|
|
moduleHtml,
|
|
actions,
|
|
login,
|
|
register,
|
|
loginMessage,
|
|
loginError,
|
|
loginSuccess,
|
|
registerMessage,
|
|
registerError,
|
|
registerSuccess,
|
|
loading,
|
|
loginUser,
|
|
registerUser,
|
|
logout,
|
|
loadModules,
|
|
selectModule,
|
|
};
|
|
},
|
|
}).mount('#app');
|
|
</script>
|
|
</body>
|
|
</html>
|
|
</script>
|
|
</body>
|
|
</html>
|