feat(admin): manage points products and redemptions
Platform operators get a points catalog screen (create, edit, publish and unpublish products with localized names, points price, stock, and ordering) and a redemption screen that lists orders and moves pending ones to fulfilled or cancelled. Both are reachable from the console navigation.
This commit is contained in:
@@ -20,6 +20,8 @@ const isAuthPage = computed(() => route.path === "/login");
|
|||||||
<NuxtLink to="/shops">{{ $t("nav.shops") }}</NuxtLink>
|
<NuxtLink to="/shops">{{ $t("nav.shops") }}</NuxtLink>
|
||||||
<NuxtLink to="/orders">{{ $t("nav.orders") }}</NuxtLink>
|
<NuxtLink to="/orders">{{ $t("nav.orders") }}</NuxtLink>
|
||||||
<NuxtLink to="/currencies">{{ $t("nav.currencies") }}</NuxtLink>
|
<NuxtLink to="/currencies">{{ $t("nav.currencies") }}</NuxtLink>
|
||||||
|
<NuxtLink to="/points-products">{{ $t("nav.pointsProducts") }}</NuxtLink>
|
||||||
|
<NuxtLink to="/points-orders">{{ $t("nav.pointsOrders") }}</NuxtLink>
|
||||||
</aside>
|
</aside>
|
||||||
<main>
|
<main>
|
||||||
<div class="main-head">
|
<div class="main-head">
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ export const enExtra = {
|
|||||||
adminHint: "Dev account: admin@vmall.local / admin1234",
|
adminHint: "Dev account: admin@vmall.local / admin1234",
|
||||||
wrongRole: "This account does not have platform administrator access.",
|
wrongRole: "This account does not have platform administrator access.",
|
||||||
},
|
},
|
||||||
|
nav: {
|
||||||
|
pointsProducts: "Points products",
|
||||||
|
pointsOrders: "Point redemptions",
|
||||||
|
},
|
||||||
admin: {
|
admin: {
|
||||||
dashboardTitle: "Platform overview",
|
dashboardTitle: "Platform overview",
|
||||||
refresh: "Refresh",
|
refresh: "Refresh",
|
||||||
@@ -30,6 +34,39 @@ export const enExtra = {
|
|||||||
orderManagement: "Order management",
|
orderManagement: "Order management",
|
||||||
currencyManagement: "Currency management",
|
currencyManagement: "Currency management",
|
||||||
pageOf: "Page {page} of {total}",
|
pageOf: "Page {page} of {total}",
|
||||||
|
pointsProductManagement: "Points catalog",
|
||||||
|
pointsOrderManagement: "Point redemptions",
|
||||||
|
newPointsProduct: "New points product",
|
||||||
|
editPointsProduct: "Edit points product",
|
||||||
|
pointsNameEn: "Name (English)",
|
||||||
|
pointsNameZh: "Name (中文)",
|
||||||
|
pointsPrice: "Points price",
|
||||||
|
pointsStock: "Stock",
|
||||||
|
pointsImage: "Image URL",
|
||||||
|
pointsPosition: "Sort order",
|
||||||
|
pointsRecommend: "Featured",
|
||||||
|
pointsPublished: "Published",
|
||||||
|
publishedOn: "Published",
|
||||||
|
publishedOff: "Draft",
|
||||||
|
savePointsProduct: "Save points product",
|
||||||
|
pointsProductSaved: "Points product saved.",
|
||||||
|
pointsNameRequired: "Both language names are required.",
|
||||||
|
pointsPriceInvalid: "Points price must be a positive integer.",
|
||||||
|
pointsStockInvalid: "Stock cannot be negative.",
|
||||||
|
redemptionNo: "Redemption no.",
|
||||||
|
redemptionCustomer: "Customer",
|
||||||
|
redemptionReward: "Reward",
|
||||||
|
redemptionPoints: "Points",
|
||||||
|
redemptionStatus: "Status",
|
||||||
|
fulfillRedemption: "Fulfil",
|
||||||
|
cancelRedemption: "Cancel",
|
||||||
|
redemptionFulfilled: "Redemption fulfilled.",
|
||||||
|
redemptionCancelled: "Redemption cancelled.",
|
||||||
|
redemptionStatuses: {
|
||||||
|
pending_fulfillment: "Pending",
|
||||||
|
fulfilled: "Fulfilled",
|
||||||
|
cancelled: "Cancelled",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -38,6 +75,10 @@ export const zhExtra = {
|
|||||||
adminHint: "开发账号:admin@vmall.local / admin1234",
|
adminHint: "开发账号:admin@vmall.local / admin1234",
|
||||||
wrongRole: "此账号没有平台管理员权限。",
|
wrongRole: "此账号没有平台管理员权限。",
|
||||||
},
|
},
|
||||||
|
nav: {
|
||||||
|
pointsProducts: "积分商品",
|
||||||
|
pointsOrders: "积分兑换单",
|
||||||
|
},
|
||||||
admin: {
|
admin: {
|
||||||
dashboardTitle: "平台概览",
|
dashboardTitle: "平台概览",
|
||||||
refresh: "刷新",
|
refresh: "刷新",
|
||||||
@@ -63,5 +104,38 @@ export const zhExtra = {
|
|||||||
orderManagement: "订单管理",
|
orderManagement: "订单管理",
|
||||||
currencyManagement: "币种管理",
|
currencyManagement: "币种管理",
|
||||||
pageOf: "第 {page} 页,共 {total} 页",
|
pageOf: "第 {page} 页,共 {total} 页",
|
||||||
|
pointsProductManagement: "积分商品目录",
|
||||||
|
pointsOrderManagement: "积分兑换单",
|
||||||
|
newPointsProduct: "新建积分商品",
|
||||||
|
editPointsProduct: "编辑积分商品",
|
||||||
|
pointsNameEn: "名称(英文)",
|
||||||
|
pointsNameZh: "名称(中文)",
|
||||||
|
pointsPrice: "积分价格",
|
||||||
|
pointsStock: "库存",
|
||||||
|
pointsImage: "图片链接",
|
||||||
|
pointsPosition: "排序",
|
||||||
|
pointsRecommend: "推荐",
|
||||||
|
pointsPublished: "已发布",
|
||||||
|
publishedOn: "已发布",
|
||||||
|
publishedOff: "草稿",
|
||||||
|
savePointsProduct: "保存积分商品",
|
||||||
|
pointsProductSaved: "积分商品已保存。",
|
||||||
|
pointsNameRequired: "中英文名称均为必填项。",
|
||||||
|
pointsPriceInvalid: "积分价格必须为正整数。",
|
||||||
|
pointsStockInvalid: "库存不能为负数。",
|
||||||
|
redemptionNo: "兑换单号",
|
||||||
|
redemptionCustomer: "用户",
|
||||||
|
redemptionReward: "兑换商品",
|
||||||
|
redemptionPoints: "积分",
|
||||||
|
redemptionStatus: "状态",
|
||||||
|
fulfillRedemption: "发货",
|
||||||
|
cancelRedemption: "取消",
|
||||||
|
redemptionFulfilled: "兑换单已发货。",
|
||||||
|
redemptionCancelled: "兑换单已取消。",
|
||||||
|
redemptionStatuses: {
|
||||||
|
pending_fulfillment: "待发货",
|
||||||
|
fulfilled: "已发货",
|
||||||
|
cancelled: "已取消",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { IntegralOrder, IntegralOrderStatus } from "@vmall/shared";
|
||||||
|
|
||||||
|
definePageMeta({ middleware: "auth" });
|
||||||
|
|
||||||
|
const { $api } = useNuxtApp();
|
||||||
|
const { locale, t } = useI18n();
|
||||||
|
|
||||||
|
const orders = ref<IntegralOrder[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const actionId = ref("");
|
||||||
|
const error = ref("");
|
||||||
|
const notice = ref("");
|
||||||
|
|
||||||
|
function rewardName(order: IntegralOrder): string {
|
||||||
|
return order.items.map((line) => line.name[locale.value] ?? line.name.en).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status: IntegralOrderStatus): string {
|
||||||
|
if (status === "fulfilled") return "green";
|
||||||
|
if (status === "cancelled") return "red";
|
||||||
|
return "orange";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const page = await $api.admin.listPointsRedemptions();
|
||||||
|
orders.value = page.items;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error.value = err instanceof Error ? err.message : t("common.error");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transition(
|
||||||
|
order: IntegralOrder,
|
||||||
|
action: "fulfill" | "cancel",
|
||||||
|
): Promise<void> {
|
||||||
|
actionId.value = order.id;
|
||||||
|
error.value = "";
|
||||||
|
notice.value = "";
|
||||||
|
try {
|
||||||
|
if (action === "fulfill") await $api.admin.fulfillRedemption(order.id);
|
||||||
|
else await $api.admin.cancelRedemption(order.id);
|
||||||
|
notice.value =
|
||||||
|
action === "fulfill"
|
||||||
|
? t("admin.redemptionFulfilled")
|
||||||
|
: t("admin.redemptionCancelled");
|
||||||
|
await load();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error.value = err instanceof Error ? err.message : t("common.error");
|
||||||
|
} finally {
|
||||||
|
actionId.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => void load());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<h1 class="page-title">{{ $t("admin.pointsOrderManagement") }}</h1>
|
||||||
|
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
|
||||||
|
<p v-if="notice" class="muted" role="status">{{ notice }}</p>
|
||||||
|
|
||||||
|
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||||
|
<div v-else-if="orders.length === 0" class="card muted">{{ $t("common.empty") }}</div>
|
||||||
|
<div v-else class="table-wrap card">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ $t("admin.redemptionNo") }}</th>
|
||||||
|
<th>{{ $t("admin.redemptionCustomer") }}</th>
|
||||||
|
<th>{{ $t("admin.redemptionReward") }}</th>
|
||||||
|
<th>{{ $t("admin.redemptionPoints") }}</th>
|
||||||
|
<th>{{ $t("admin.created") }}</th>
|
||||||
|
<th>{{ $t("admin.redemptionStatus") }}</th>
|
||||||
|
<th>{{ $t("common.actions") }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="order in orders" :key="order.id">
|
||||||
|
<td>{{ order.order_no }}</td>
|
||||||
|
<td class="muted">{{ order.user_id }}</td>
|
||||||
|
<td>{{ rewardName(order) }}</td>
|
||||||
|
<td>{{ order.total_points }}</td>
|
||||||
|
<td>{{ order.created_at.slice(0, 10) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="statusClass(order.status)">
|
||||||
|
{{ $t(`admin.redemptionStatuses.${order.status}`) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="action-row">
|
||||||
|
<template v-if="order.status === 'pending_fulfillment'">
|
||||||
|
<button
|
||||||
|
class="btn sm primary"
|
||||||
|
type="button"
|
||||||
|
:disabled="actionId === order.id"
|
||||||
|
@click="transition(order, 'fulfill')"
|
||||||
|
>
|
||||||
|
{{ $t("admin.fulfillRedemption") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="actionId === order.id"
|
||||||
|
@click="transition(order, 'cancel')"
|
||||||
|
>
|
||||||
|
{{ $t("admin.cancelRedemption") }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.table {
|
||||||
|
min-width: 980px;
|
||||||
|
}
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { IntegralProduct, IntegralProductInput } from "@vmall/shared";
|
||||||
|
|
||||||
|
definePageMeta({ middleware: "auth" });
|
||||||
|
|
||||||
|
const { $api } = useNuxtApp();
|
||||||
|
const { locale, t } = useI18n();
|
||||||
|
|
||||||
|
const products = ref<IntegralProduct[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const saving = ref(false);
|
||||||
|
const actionId = ref("");
|
||||||
|
const error = ref("");
|
||||||
|
const notice = ref("");
|
||||||
|
const editingId = ref("");
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
nameEn: "",
|
||||||
|
nameZh: "",
|
||||||
|
image: "",
|
||||||
|
pointsPrice: 0,
|
||||||
|
stock: 0,
|
||||||
|
position: 0,
|
||||||
|
recommend: false,
|
||||||
|
published: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function localizedName(name: Record<string, string>): string {
|
||||||
|
return name[locale.value] ?? name.en ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm(): void {
|
||||||
|
editingId.value = "";
|
||||||
|
form.nameEn = "";
|
||||||
|
form.nameZh = "";
|
||||||
|
form.image = "";
|
||||||
|
form.pointsPrice = 0;
|
||||||
|
form.stock = 0;
|
||||||
|
form.position = 0;
|
||||||
|
form.recommend = false;
|
||||||
|
form.published = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
products.value = await $api.admin.listPointsProducts();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error.value = err instanceof Error ? err.message : t("common.error");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function payload(): IntegralProductInput {
|
||||||
|
return {
|
||||||
|
name: { en: form.nameEn.trim(), zh: form.nameZh.trim() },
|
||||||
|
image: form.image.trim() || null,
|
||||||
|
points_price: Number(form.pointsPrice),
|
||||||
|
stock: Number(form.stock),
|
||||||
|
position: Number(form.position),
|
||||||
|
recommend: form.recommend,
|
||||||
|
published: form.published,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(): Promise<void> {
|
||||||
|
error.value = "";
|
||||||
|
notice.value = "";
|
||||||
|
const body = payload();
|
||||||
|
if (!body.name.en || !body.name.zh) {
|
||||||
|
error.value = t("admin.pointsNameRequired");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(body.points_price > 0)) {
|
||||||
|
error.value = t("admin.pointsPriceInvalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (body.stock < 0) {
|
||||||
|
error.value = t("admin.pointsStockInvalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await $api.admin.updatePointsProduct(editingId.value, body);
|
||||||
|
} else {
|
||||||
|
await $api.admin.createPointsProduct(body);
|
||||||
|
}
|
||||||
|
notice.value = t("admin.pointsProductSaved");
|
||||||
|
resetForm();
|
||||||
|
await load();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error.value = err instanceof Error ? err.message : t("common.error");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit(product: IntegralProduct): void {
|
||||||
|
editingId.value = product.id;
|
||||||
|
form.nameEn = product.name.en ?? "";
|
||||||
|
form.nameZh = product.name.zh ?? "";
|
||||||
|
form.image = product.image ?? "";
|
||||||
|
form.pointsPrice = product.points_price;
|
||||||
|
form.stock = product.stock;
|
||||||
|
form.position = product.position;
|
||||||
|
form.recommend = product.recommend;
|
||||||
|
form.published = product.published;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePublished(product: IntegralProduct): Promise<void> {
|
||||||
|
actionId.value = product.id;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
await $api.admin.setPointsProductPublished(product.id, !product.published);
|
||||||
|
await load();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
error.value = err instanceof Error ? err.message : t("common.error");
|
||||||
|
} finally {
|
||||||
|
actionId.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => void load());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<h1 class="page-title">{{ $t("admin.pointsProductManagement") }}</h1>
|
||||||
|
<p v-if="error" class="error-text" role="alert">{{ error }}</p>
|
||||||
|
<p v-if="notice" class="muted" role="status">{{ notice }}</p>
|
||||||
|
|
||||||
|
<section class="card create-card">
|
||||||
|
<h2>{{ editingId ? $t("admin.editPointsProduct") : $t("admin.newPointsProduct") }}</h2>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="pp-name-en">{{ $t("admin.pointsNameEn") }}</label>
|
||||||
|
<input id="pp-name-en" v-model="form.nameEn" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="pp-name-zh">{{ $t("admin.pointsNameZh") }}</label>
|
||||||
|
<input id="pp-name-zh" v-model="form.nameZh" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="pp-image">{{ $t("admin.pointsImage") }}</label>
|
||||||
|
<input id="pp-image" v-model="form.image" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="pp-price">{{ $t("admin.pointsPrice") }}</label>
|
||||||
|
<input id="pp-price" v-model.number="form.pointsPrice" min="1" step="1" type="number" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="pp-stock">{{ $t("admin.pointsStock") }}</label>
|
||||||
|
<input id="pp-stock" v-model.number="form.stock" min="0" step="1" type="number" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="pp-position">{{ $t("admin.pointsPosition") }}</label>
|
||||||
|
<input id="pp-position" v-model.number="form.position" step="1" type="number" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="checkbox-field">
|
||||||
|
<input v-model="form.recommend" type="checkbox" />
|
||||||
|
{{ $t("admin.pointsRecommend") }}
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-field">
|
||||||
|
<input v-model="form.published" type="checkbox" />
|
||||||
|
{{ $t("admin.pointsPublished") }}
|
||||||
|
</label>
|
||||||
|
<div class="action-row">
|
||||||
|
<button class="btn primary" type="button" :disabled="saving" @click="submit">
|
||||||
|
{{ saving ? $t("common.loading") : $t("admin.savePointsProduct") }}
|
||||||
|
</button>
|
||||||
|
<button v-if="editingId" class="btn" type="button" @click="resetForm">
|
||||||
|
{{ $t("common.cancel") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p v-if="loading" class="muted">{{ $t("common.loading") }}</p>
|
||||||
|
<div v-else-if="products.length === 0" class="card muted">{{ $t("common.empty") }}</div>
|
||||||
|
<div v-else class="table-wrap card">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ $t("admin.pointsNameEn") }}</th>
|
||||||
|
<th>{{ $t("admin.pointsPrice") }}</th>
|
||||||
|
<th>{{ $t("admin.pointsStock") }}</th>
|
||||||
|
<th>{{ $t("admin.pointsPosition") }}</th>
|
||||||
|
<th>{{ $t("admin.pointsRecommend") }}</th>
|
||||||
|
<th>{{ $t("common.status") }}</th>
|
||||||
|
<th>{{ $t("common.actions") }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="product in products" :key="product.id">
|
||||||
|
<td>{{ localizedName(product.name) }}</td>
|
||||||
|
<td>{{ product.points_price }}</td>
|
||||||
|
<td>{{ product.stock }}</td>
|
||||||
|
<td>{{ product.position }}</td>
|
||||||
|
<td>{{ product.recommend ? $t("common.yes") : $t("common.no") }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="product.published ? 'green' : 'orange'">
|
||||||
|
{{ product.published ? $t("admin.publishedOn") : $t("admin.publishedOff") }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="action-row">
|
||||||
|
<button class="btn sm" type="button" @click="edit(product)">
|
||||||
|
{{ $t("common.edit") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn sm primary"
|
||||||
|
type="button"
|
||||||
|
:disabled="actionId === product.id"
|
||||||
|
@click="togglePublished(product)"
|
||||||
|
>
|
||||||
|
{{ product.published ? $t("product.unpublish") : $t("product.publish") }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.create-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.create-card h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.checkbox-field {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
.checkbox-field input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.table {
|
||||||
|
min-width: 900px;
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user