mirror of
https://github.com/System-End/scraps.git
synced 2026-04-19 22:05:09 +00:00
perm del and shop ricing
This commit is contained in:
parent
c177a69a51
commit
7ec39c4ccc
8 changed files with 333 additions and 30 deletions
|
|
@ -14,6 +14,7 @@ import slack from './routes/slack'
|
|||
import { startHackatimeSync } from './lib/hackatime-sync'
|
||||
import { startAirtableSync } from './lib/airtable-sync'
|
||||
import { startScrapsPayout } from './lib/scraps-payout'
|
||||
import { updateShopItemPricing } from './lib/shop-pricing'
|
||||
|
||||
const api = new Elysia()
|
||||
.use(authRoutes)
|
||||
|
|
@ -40,6 +41,9 @@ console.log(
|
|||
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
|
||||
)
|
||||
|
||||
// Update shop item pricing on startup
|
||||
updateShopItemPricing()
|
||||
|
||||
// Start background syncs (skip in dev mode)
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
startHackatimeSync()
|
||||
|
|
|
|||
40
backend/src/lib/shop-pricing.ts
Normal file
40
backend/src/lib/shop-pricing.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { db } from '../db'
|
||||
import { shopItemsTable } from '../schemas/shop'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { calculateShopItemPricing, SCRAPS_PER_DOLLAR } from './scraps'
|
||||
|
||||
export async function updateShopItemPricing() {
|
||||
try {
|
||||
const items = await db
|
||||
.select({
|
||||
id: shopItemsTable.id,
|
||||
name: shopItemsTable.name,
|
||||
price: shopItemsTable.price,
|
||||
count: shopItemsTable.count
|
||||
})
|
||||
.from(shopItemsTable)
|
||||
|
||||
let updated = 0
|
||||
for (const item of items) {
|
||||
const monetaryValue = item.price / SCRAPS_PER_DOLLAR
|
||||
const pricing = calculateShopItemPricing(monetaryValue, item.count)
|
||||
|
||||
await db
|
||||
.update(shopItemsTable)
|
||||
.set({
|
||||
baseProbability: pricing.baseProbability,
|
||||
baseUpgradeCost: pricing.baseUpgradeCost,
|
||||
costMultiplier: pricing.costMultiplier,
|
||||
boostAmount: pricing.boostAmount,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(shopItemsTable.id, item.id))
|
||||
|
||||
updated++
|
||||
}
|
||||
|
||||
console.log(`[STARTUP] Updated pricing for ${updated} shop items`)
|
||||
} catch (err) {
|
||||
console.error('[STARTUP] Failed to update shop item pricing:', err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1553,7 +1553,7 @@ admin.patch('/orders/:id', async ({ params, body, headers, status }) => {
|
|||
|
||||
const { status: orderStatus, notes, isFulfilled } = body as { status?: string; notes?: string; isFulfilled?: boolean }
|
||||
|
||||
const validStatuses = ['pending', 'processing', 'shipped', 'delivered', 'cancelled']
|
||||
const validStatuses = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'deleted']
|
||||
if (orderStatus && !validStatuses.includes(orderStatus)) {
|
||||
return status(400, { error: 'Invalid status' })
|
||||
}
|
||||
|
|
@ -1768,8 +1768,67 @@ admin.get('/export/review-json', async ({ headers, status }) => {
|
|||
}
|
||||
})
|
||||
|
||||
// Undo a shop order (admin only) - deletes the order and restores stock
|
||||
admin.post('/orders/:id/undo', async ({ params, headers, status }) => {
|
||||
// Soft-delete a shop order (admin only) - marks the order as deleted
|
||||
admin.post('/orders/:id/soft-delete', async ({ params, headers, status }) => {
|
||||
try {
|
||||
const user = await requireAdmin(headers as Record<string, string>)
|
||||
if (!user) return status(401, { error: 'Unauthorized' })
|
||||
|
||||
const orderId = parseInt(params.id)
|
||||
|
||||
const order = await db
|
||||
.select({ id: shopOrdersTable.id, status: shopOrdersTable.status })
|
||||
.from(shopOrdersTable)
|
||||
.where(eq(shopOrdersTable.id, orderId))
|
||||
.limit(1)
|
||||
|
||||
if (!order[0]) return status(404, { error: 'Order not found' })
|
||||
if (order[0].status === 'deleted') return status(400, { error: 'Order is already deleted' })
|
||||
|
||||
await db
|
||||
.update(shopOrdersTable)
|
||||
.set({ status: 'deleted', updatedAt: new Date() })
|
||||
.where(eq(shopOrdersTable.id, orderId))
|
||||
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return status(500, { error: 'Failed to soft-delete order' })
|
||||
}
|
||||
})
|
||||
|
||||
// Restore a soft-deleted shop order (admin only)
|
||||
admin.post('/orders/:id/restore', async ({ params, headers, status }) => {
|
||||
try {
|
||||
const user = await requireAdmin(headers as Record<string, string>)
|
||||
if (!user) return status(401, { error: 'Unauthorized' })
|
||||
|
||||
const orderId = parseInt(params.id)
|
||||
|
||||
const order = await db
|
||||
.select({ id: shopOrdersTable.id, status: shopOrdersTable.status })
|
||||
.from(shopOrdersTable)
|
||||
.where(eq(shopOrdersTable.id, orderId))
|
||||
.limit(1)
|
||||
|
||||
if (!order[0]) return status(404, { error: 'Order not found' })
|
||||
if (order[0].status !== 'deleted') return status(400, { error: 'Order is not deleted' })
|
||||
|
||||
await db
|
||||
.update(shopOrdersTable)
|
||||
.set({ status: 'pending', updatedAt: new Date() })
|
||||
.where(eq(shopOrdersTable.id, orderId))
|
||||
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return status(500, { error: 'Failed to restore order' })
|
||||
}
|
||||
})
|
||||
|
||||
// Undo/refund a shop order (admin only) - must be soft-deleted first
|
||||
// Adds a bonus to refund scraps, restores inventory, keeps order row for timeline
|
||||
admin.delete('/orders/:id', async ({ params, headers, status }) => {
|
||||
try {
|
||||
const user = await requireAdmin(headers as Record<string, string>)
|
||||
if (!user) return status(401, { error: 'Unauthorized' })
|
||||
|
|
@ -1779,25 +1838,29 @@ admin.post('/orders/:id/undo', async ({ params, headers, status }) => {
|
|||
const order = await db
|
||||
.select({
|
||||
id: shopOrdersTable.id,
|
||||
userId: shopOrdersTable.userId,
|
||||
status: shopOrdersTable.status,
|
||||
shopItemId: shopOrdersTable.shopItemId,
|
||||
quantity: shopOrdersTable.quantity,
|
||||
totalPrice: shopOrdersTable.totalPrice,
|
||||
orderType: shopOrdersTable.orderType
|
||||
userId: shopOrdersTable.userId,
|
||||
itemName: shopItemsTable.name
|
||||
})
|
||||
.from(shopOrdersTable)
|
||||
.innerJoin(shopItemsTable, eq(shopOrdersTable.shopItemId, shopItemsTable.id))
|
||||
.where(eq(shopOrdersTable.id, orderId))
|
||||
.limit(1)
|
||||
|
||||
if (!order[0]) {
|
||||
return status(404, { error: 'Order not found' })
|
||||
}
|
||||
if (!order[0]) return status(404, { error: 'Order not found' })
|
||||
if (order[0].status !== 'deleted') return status(400, { error: 'Order must be marked as deleted before it can be refunded' })
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Delete the order (scraps refund happens automatically since spent calculation uses shop_orders)
|
||||
await tx.delete(shopOrdersTable).where(eq(shopOrdersTable.id, orderId))
|
||||
await tx.insert(userBonusesTable).values({
|
||||
userId: order[0].userId,
|
||||
amount: order[0].totalPrice,
|
||||
reason: `order refund: ${order[0].itemName} (order #${orderId})`,
|
||||
givenBy: user.id
|
||||
})
|
||||
|
||||
// Restore item stock
|
||||
await tx
|
||||
.update(shopItemsTable)
|
||||
.set({
|
||||
|
|
@ -1805,16 +1868,12 @@ admin.post('/orders/:id/undo', async ({ params, headers, status }) => {
|
|||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(shopItemsTable.id, order[0].shopItemId))
|
||||
|
||||
// If this was a luck_win, also delete related refinery orders and spending history
|
||||
// (the win consumed refinery orders, so undoing the win should restore them...
|
||||
// but we can't restore deleted refinery orders. Just undo the purchase itself.)
|
||||
})
|
||||
|
||||
return { success: true, refundedScraps: order[0].totalPrice }
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return status(500, { error: 'Failed to undo order' })
|
||||
return status(500, { error: 'Failed to refund order' })
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -554,7 +554,14 @@ export default {
|
|||
of: 'of',
|
||||
sortOldestFirst: 'oldest first',
|
||||
sortNewestFirst: 'newest first',
|
||||
sort: 'sort'
|
||||
sort: 'sort',
|
||||
removed: 'removed',
|
||||
softDelete: 'remove',
|
||||
restore: 'restore',
|
||||
permanentDelete: 'permanently delete',
|
||||
confirmSoftDelete: 'are you sure you want to remove this order? it can be restored later.',
|
||||
confirmPermanentDelete: 'are you sure you want to permanently delete this order? this will refund the scraps and restore inventory. this cannot be undone.',
|
||||
adminUserPage: 'admin user page'
|
||||
},
|
||||
auth: {
|
||||
error: 'error',
|
||||
|
|
|
|||
|
|
@ -557,7 +557,14 @@ export default {
|
|||
of: 'de',
|
||||
sortOldestFirst: 'más antiguos primero',
|
||||
sortNewestFirst: 'más recientes primero',
|
||||
sort: 'ordenar'
|
||||
sort: 'ordenar',
|
||||
removed: 'eliminados',
|
||||
softDelete: 'eliminar',
|
||||
restore: 'restaurar',
|
||||
permanentDelete: 'eliminar permanentemente',
|
||||
confirmSoftDelete: '¿estás seguro de que quieres eliminar este pedido? se puede restaurar después.',
|
||||
confirmPermanentDelete: '¿estás seguro de que quieres eliminar permanentemente este pedido? esto reembolsará los scraps y restaurará el inventario. esto no se puede deshacer.',
|
||||
adminUserPage: 'página de admin del usuario'
|
||||
},
|
||||
auth: {
|
||||
error: 'error',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Check, X, Package, Clock, Truck, CheckCircle, XCircle } from '@lucide/svelte';
|
||||
import { Check, X, Package, Clock, Truck, CheckCircle, XCircle, Trash2, RotateCcw } from '@lucide/svelte';
|
||||
import { getUser } from '$lib/auth-client';
|
||||
import { API_URL } from '$lib/config';
|
||||
import { t } from '$lib/i18n';
|
||||
|
|
@ -59,13 +59,18 @@
|
|||
let orders = $state<Order[]>([]);
|
||||
let loading = $state(true);
|
||||
let filter = $state<'all' | 'pending' | 'fulfilled'>('all');
|
||||
let confirmModal = $state<{ type: 'soft-delete' | 'permanent-delete'; order: Order } | null>(null);
|
||||
let actionLoading = $state(false);
|
||||
|
||||
let activeOrders = $derived(orders.filter((o) => o.status !== 'deleted'));
|
||||
let deletedOrders = $derived(orders.filter((o) => o.status === 'deleted'));
|
||||
|
||||
let filteredOrders = $derived(
|
||||
filter === 'all'
|
||||
? orders
|
||||
? activeOrders
|
||||
: filter === 'pending'
|
||||
? orders.filter((o) => !o.isFulfilled)
|
||||
: orders.filter((o) => o.isFulfilled)
|
||||
? activeOrders.filter((o) => !o.isFulfilled)
|
||||
: activeOrders.filter((o) => o.isFulfilled)
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
|
|
@ -110,6 +115,56 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function softDeleteOrder(order: Order) {
|
||||
actionLoading = true;
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/orders/${order.id}/soft-delete`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
orders = orders.map((o) => (o.id === order.id ? { ...o, status: 'deleted' } : o));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to soft-delete order:', e);
|
||||
} finally {
|
||||
actionLoading = false;
|
||||
confirmModal = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreOrder(order: Order) {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/orders/${order.id}/restore`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
orders = orders.map((o) => (o.id === order.id ? { ...o, status: 'pending' } : o));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to restore order:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function permanentDeleteOrder(order: Order) {
|
||||
actionLoading = true;
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/admin/orders/${order.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
orders = orders.filter((o) => o.id !== order.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to permanently delete order:', e);
|
||||
} finally {
|
||||
actionLoading = false;
|
||||
confirmModal = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
|
|
@ -132,6 +187,8 @@
|
|||
return CheckCircle;
|
||||
case 'cancelled':
|
||||
return XCircle;
|
||||
case 'deleted':
|
||||
return Trash2;
|
||||
default:
|
||||
return Clock;
|
||||
}
|
||||
|
|
@ -149,6 +206,8 @@
|
|||
return 'bg-green-100 text-green-700 border-green-600';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-700 border-red-600';
|
||||
case 'deleted':
|
||||
return 'bg-gray-100 text-gray-700 border-gray-600';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 border-gray-600';
|
||||
}
|
||||
|
|
@ -176,7 +235,7 @@
|
|||
? 'bg-black text-white'
|
||||
: 'hover:border-dashed'}"
|
||||
>
|
||||
{$t.admin.all} ({orders.length})
|
||||
{$t.admin.all} ({activeOrders.length})
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (filter = 'pending')}
|
||||
|
|
@ -185,7 +244,7 @@
|
|||
? 'bg-black text-white'
|
||||
: 'hover:border-dashed'}"
|
||||
>
|
||||
{$t.admin.pending} ({orders.filter((o) => !o.isFulfilled).length})
|
||||
{$t.admin.pending} ({activeOrders.filter((o) => !o.isFulfilled).length})
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (filter = 'fulfilled')}
|
||||
|
|
@ -194,7 +253,7 @@
|
|||
? 'bg-black text-white'
|
||||
: 'hover:border-dashed'}"
|
||||
>
|
||||
{$t.admin.fulfilled} ({orders.filter((o) => o.isFulfilled).length})
|
||||
{$t.admin.fulfilled} ({activeOrders.filter((o) => o.isFulfilled).length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -308,7 +367,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="shrink-0">
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
onclick={() => toggleFulfilled(order)}
|
||||
class="flex cursor-pointer items-center gap-2 rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 {order.isFulfilled
|
||||
|
|
@ -323,10 +382,120 @@
|
|||
{$t.admin.fulfill}
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (confirmModal = { type: 'soft-delete', order })}
|
||||
class="flex cursor-pointer items-center gap-1 rounded-full border-4 border-black px-3 py-2 font-bold transition-all duration-200 hover:border-dashed"
|
||||
title={$t.admin.softDelete}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Removed Orders Section -->
|
||||
{#if deletedOrders.length > 0}
|
||||
<div class="mt-12">
|
||||
<h2 class="mb-4 flex items-center gap-2 text-2xl font-bold text-gray-400">
|
||||
<Trash2 size={24} />
|
||||
{$t.admin.removed} ({deletedOrders.length})
|
||||
</h2>
|
||||
<div class="grid gap-4">
|
||||
{#each deletedOrders as order}
|
||||
<div class="rounded-2xl border-4 border-gray-300 bg-gray-50 p-4 opacity-60">
|
||||
<div class="flex items-start gap-4">
|
||||
<img
|
||||
src={order.itemImage}
|
||||
alt={order.itemName}
|
||||
class="h-16 w-16 shrink-0 rounded-lg border-2 border-gray-300 object-cover grayscale"
|
||||
/>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<h3 class="text-lg font-bold text-gray-500">{order.itemName}</h3>
|
||||
<span class="text-gray-400">×{order.quantity}</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<a href="/admin/users/{order.userId}" class="text-sm font-bold text-gray-500 hover:underline">
|
||||
@{order.username}
|
||||
</a>
|
||||
<span class="text-gray-300">•</span>
|
||||
<span class="text-sm text-gray-400">{formatDate(order.createdAt)}</span>
|
||||
<span class="text-gray-300">•</span>
|
||||
<span class="text-sm font-bold text-gray-500">{order.totalPrice} scraps</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
onclick={() => restoreOrder(order)}
|
||||
class="flex cursor-pointer items-center gap-2 rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
{$t.admin.restore}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (confirmModal = { type: 'permanent-delete', order })}
|
||||
class="flex cursor-pointer items-center gap-2 rounded-full border-4 border-red-600 bg-red-600 px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{$t.admin.permanentDelete}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
{#if confirmModal}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick={(e) => e.target === e.currentTarget && (confirmModal = null)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (confirmModal = null)}
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-2xl border-4 border-black bg-white p-6">
|
||||
<h2 class="mb-4 text-2xl font-bold">
|
||||
{confirmModal.type === 'soft-delete' ? $t.admin.softDelete : $t.admin.permanentDelete}
|
||||
</h2>
|
||||
<p class="mb-6 text-gray-600">
|
||||
{confirmModal.type === 'soft-delete' ? $t.admin.confirmSoftDelete : $t.admin.confirmPermanentDelete}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={() => (confirmModal = null)}
|
||||
disabled={actionLoading}
|
||||
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{$t.common.cancel}
|
||||
</button>
|
||||
{#if confirmModal.type === 'soft-delete'}
|
||||
<button
|
||||
onclick={() => confirmModal && softDeleteOrder(confirmModal.order)}
|
||||
disabled={actionLoading}
|
||||
class="flex-1 cursor-pointer rounded-full border-4 border-black bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? '...' : $t.admin.softDelete}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => confirmModal && permanentDeleteOrder(confirmModal.order)}
|
||||
disabled={actionLoading}
|
||||
class="flex-1 cursor-pointer rounded-full border-4 border-red-600 bg-red-600 px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? '...' : $t.admin.permanentDelete}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -240,13 +240,23 @@
|
|||
undoingOrder = orderId;
|
||||
showUndoConfirm = null;
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/orders/${orderId}/undo`, {
|
||||
const softDeleteRes = await fetch(`${API_URL}/admin/orders/${orderId}/soft-delete`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.error) {
|
||||
alert(result.error);
|
||||
const softDeleteResult = await softDeleteRes.json();
|
||||
if (softDeleteResult.error) {
|
||||
console.error(softDeleteResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const refundRes = await fetch(`${API_URL}/admin/orders/${orderId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
const refundResult = await refundRes.json();
|
||||
if (refundResult.error) {
|
||||
console.error(refundResult.error);
|
||||
return;
|
||||
}
|
||||
fetchTimeline();
|
||||
|
|
|
|||
|
|
@ -209,6 +209,13 @@
|
|||
<Pencil size={12} />
|
||||
{$t.profile.editRole}
|
||||
</button>
|
||||
<a
|
||||
href="/admin/users/{profileUser.id}"
|
||||
class="flex cursor-pointer items-center gap-1 rounded-full border-2 border-black px-2 py-0.5 text-xs font-bold transition-all hover:border-dashed"
|
||||
>
|
||||
<Shield size={12} />
|
||||
{$t.admin.adminUserPage}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue