Files
role-admin/src/views/employees/index.vue
T
2026-06-02 14:23:31 +08:00

1082 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import {
ElMessage,
ElMessageBox,
ElNotification,
type FormInstance,
type FormRules
} from "element-plus";
import {
createEmployee,
deleteEmployee,
getEmployee,
listEmployees,
listAllStores,
listRoles,
resetEmployeePassword,
updateEmployee,
updateEmployeeStatus,
type Employee,
type EmployeePayload,
type EmployeeStatus,
type EmployeeStatusTag,
type RoleOption,
type Store
} from "@/api/access";
import { hasMenuAction, hasPerms } from "@/utils/auth";
import Plus from "~icons/ep/plus";
import Search from "~icons/ep/search";
import Refresh from "~icons/ep/refresh";
import EditPen from "~icons/ep/edit-pen";
import Delete from "~icons/ep/delete";
import CircleCheck from "~icons/ep/circle-check";
import CircleClose from "~icons/ep/circle-close";
import Key from "~icons/ep/key";
import CopyDocument from "~icons/ep/copy-document";
defineOptions({
name: "EmployeeManagement"
});
/** 表单状态复用后端员工 payload,id 用来切换创建/更新接口。 */
type EmployeeFormState = EmployeePayload & {
id?: number;
};
type PasswordFormState = {
employeeId?: number;
employeeName: string;
reason: string;
};
/** 员工手机号按中国大陆手机号做前端第一层校验,最终唯一性仍由后端保证。 */
const phonePattern = /^1[3-9]\d{9}$/;
const bindableRoleCodes = new Set([
"store_manager",
"cashier",
"kitchen",
"part_time",
"admin"
]);
const tableLoading = ref(false);
const storeLoading = ref(false);
const roleLoading = ref(false);
const submitLoading = ref(false);
const passwordSubmitLoading = ref(false);
const dialogVisible = ref(false);
const passwordDialogVisible = ref(false);
const temporaryPasswordVisible = ref(false);
const formRef = ref<FormInstance>();
const passwordFormRef = ref<FormInstance>();
const employees = ref<Employee[]>([]);
const stores = ref<Store[]>([]);
const roles = ref<RoleOption[]>([]);
const originalStoreId = ref<number>();
const temporaryPassword = ref("");
const query = reactive({
storeId: undefined as number | undefined,
status: undefined as EmployeeStatus | undefined,
keyword: "",
page: 1,
pageSize: 20
});
/** 员工列表由后端分页,这里只保存当前查询返回的分页摘要。 */
const pagination = reactive({
total: 0,
totalPages: 0
});
const form = reactive<EmployeeFormState>({
storeId: undefined as unknown as number,
name: "",
phone: "",
status: "ACTIVE",
remark: "",
roleIds: []
});
const passwordForm = reactive<PasswordFormState>({
employeeId: undefined,
employeeName: "",
reason: ""
});
const rules: FormRules<EmployeeFormState> = {
storeId: [{ required: true, message: "请选择所属门店", trigger: "change" }],
name: [
{ required: true, message: "请输入员工姓名", trigger: "blur" },
{ max: 50, message: "员工姓名不能超过 50 个字符", trigger: "blur" }
],
phone: [
{ required: true, message: "请输入手机号", trigger: "blur" },
{
pattern: phonePattern,
message: "请输入正确的中国大陆手机号",
trigger: "blur"
}
],
status: [{ required: true, message: "请选择员工状态", trigger: "change" }],
roleIds: [{ type: "array", message: "请选择角色", trigger: "change" }],
remark: [{ max: 500, message: "备注不能超过 500 个字符", trigger: "blur" }]
};
const passwordRules: FormRules<PasswordFormState> = {
reason: [
{ required: true, message: "请填写重置原因", trigger: "blur" },
{
min: 4,
max: 255,
message: "原因需要在 4-255 个字符之间",
trigger: "blur"
}
]
};
const activeCount = computed(
() => employees.value.filter(item => item.status === "ACTIVE").length
);
const inactiveCount = computed(
() => employees.value.filter(item => item.status === "INACTIVE").length
);
const dialogTitle = computed(() => (form.id ? "编辑员工" : "新增员工"));
const canCreateEmployee = computed(() => hasMenuAction("employees", "create"));
const canUpdateEmployee = computed(() => hasMenuAction("employees", "update"));
const canDeleteEmployee = computed(() => hasMenuAction("employees", "delete"));
const canResetCredential = computed(() => hasPerms("credential:reset"));
const canOperateEmployee = computed(
() =>
canUpdateEmployee.value ||
canDeleteEmployee.value ||
canResetCredential.value
);
const canViewAllEmployees = computed(() => hasPerms("employee:view:all"));
function getErrorMessage(error: unknown, fallback: string) {
const message = (
error as { response?: { data?: { error?: { message?: string } } } }
)?.response?.data?.error?.message;
if (message) return message;
if (error instanceof Error && error.message) return error.message;
return fallback;
}
function formatTime(value: string) {
return new Date(value).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
function getStoreOptionLabel(store: Store) {
return store.status === "ACTIVE" ? store.name : `${store.name}(停用)`;
}
function getEmployeeStatusTagType(tag: EmployeeStatusTag) {
return tag.variant === "default" ? "info" : tag.variant;
}
function resolveStatusTags(row: Employee): EmployeeStatusTag[] {
if (row.statusTags?.length) return row.statusTags;
const tags: EmployeeStatusTag[] = [
row.status === "ACTIVE"
? { code: "EMPLOYEE_ACTIVE", label: "员工启用", variant: "success" }
: { code: "EMPLOYEE_INACTIVE", label: "员工停用", variant: "default" }
];
if (row.storeStatus === "INACTIVE") {
tags.push({
code: "STORE_INACTIVE",
label: "门店被禁用",
variant: "warning"
});
}
return tags;
}
function resetFormState() {
originalStoreId.value = undefined;
Object.assign(form, {
id: undefined,
storeId: undefined,
name: "",
phone: "",
status: "ACTIVE",
remark: "",
roleIds: []
});
formRef.value?.clearValidate();
}
function resetPasswordFormState() {
temporaryPassword.value = "";
Object.assign(passwordForm, {
employeeId: undefined,
employeeName: "",
reason: ""
});
passwordFormRef.value?.clearValidate();
}
/** 清理提交数据,避免把仅包含空格的备注写入后端。 */
function buildPayload(): EmployeePayload {
return {
storeId: form.storeId,
name: form.name.trim(),
phone: form.phone.trim(),
status: form.status,
remark: form.remark?.trim() ? form.remark.trim() : null,
roleIds: form.roleIds
};
}
/** 门店可能在其他页面被新增,所以下拉展开时单独拉取最新选项。 */
async function fetchStores() {
if (
!canViewAllEmployees.value &&
!canCreateEmployee.value &&
!canUpdateEmployee.value
) {
return;
}
storeLoading.value = true;
try {
const storeResult = await listAllStores({ includeInactive: true });
stores.value = storeResult.data;
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载门店选项失败"));
} finally {
storeLoading.value = false;
}
}
function handleStoreDropdownVisible(visible: boolean) {
if (visible) {
fetchStores();
}
}
/** 角色是员工表单基础字典,页面进入时加载一次即可。 */
async function fetchRoles() {
if (!canCreateEmployee.value && !canUpdateEmployee.value) return;
roleLoading.value = true;
try {
const roleResult = await listRoles();
roles.value = roleResult.data.filter(role =>
bindableRoleCodes.has(role.code)
);
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载角色选项失败"));
} finally {
roleLoading.value = false;
}
}
/** 员工列表筛选、分页都交给后端,前端只传递当前查询条件。 */
async function fetchEmployees() {
tableLoading.value = true;
try {
const result = await listEmployees({
storeId: query.storeId,
status: query.status,
keyword: query.keyword.trim() || undefined,
page: query.page,
pageSize: query.pageSize
});
employees.value = result.data.items;
pagination.total = result.data.pagination.total;
pagination.totalPages = result.data.pagination.totalPages;
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载员工列表失败"));
} finally {
tableLoading.value = false;
}
}
function handleSearch() {
query.page = 1;
fetchEmployees();
}
function handleReset() {
query.storeId = undefined;
query.status = undefined;
query.keyword = "";
query.page = 1;
query.pageSize = 20;
fetchEmployees();
}
function handlePageChange(page: number) {
query.page = page;
fetchEmployees();
}
function handleSizeChange(pageSize: number) {
query.page = 1;
query.pageSize = pageSize;
fetchEmployees();
}
function openCreateDialog() {
if (!canCreateEmployee.value) return;
resetFormState();
dialogVisible.value = true;
fetchStores();
}
/** 编辑前重新拉详情,确保角色绑定不是来自列表摘要的过期数据。 */
async function openEditDialog(row: Employee) {
if (!canUpdateEmployee.value) return;
try {
const result = await getEmployee(row.id);
const employee = result.data;
originalStoreId.value = employee.storeId;
Object.assign(form, {
id: employee.id,
storeId: employee.storeId,
name: employee.name,
phone: employee.phone,
status: employee.status,
remark: employee.remark ?? "",
roleIds: employee.roles.map(role => role.id)
});
dialogVisible.value = true;
fetchStores();
formRef.value?.clearValidate();
} catch (error) {
ElMessage.error(getErrorMessage(error, "加载员工详情失败"));
}
}
async function submitForm() {
if (form.id ? !canUpdateEmployee.value : !canCreateEmployee.value) return;
await formRef.value?.validate();
submitLoading.value = true;
try {
const payload = buildPayload();
if (form.id) {
const updatePayload: Partial<EmployeePayload> = { ...payload };
if (payload.storeId === originalStoreId.value) {
delete updatePayload.storeId;
}
await updateEmployee(form.id, updatePayload);
ElMessage.success("员工信息已更新");
} else {
await createEmployee(payload);
ElMessage.success("员工已创建");
}
dialogVisible.value = false;
fetchEmployees();
} catch (error) {
ElMessage.error(getErrorMessage(error, "保存员工失败"));
} finally {
submitLoading.value = false;
}
}
function openPasswordDialog(row: Employee) {
if (!canResetCredential.value) return;
resetPasswordFormState();
Object.assign(passwordForm, {
employeeId: row.id,
employeeName: row.name,
reason: ""
});
passwordDialogVisible.value = true;
}
async function submitPasswordForm() {
if (!canResetCredential.value || !passwordForm.employeeId) return;
await passwordFormRef.value?.validate();
passwordSubmitLoading.value = true;
try {
const result = await resetEmployeePassword(passwordForm.employeeId, {
reason: passwordForm.reason.trim()
});
passwordDialogVisible.value = false;
temporaryPassword.value = result.data.temporaryPassword;
temporaryPasswordVisible.value = true;
fetchEmployees();
} catch (error) {
ElMessage.error(getErrorMessage(error, "重置密码失败"));
} finally {
passwordSubmitLoading.value = false;
}
}
async function copyTemporaryPassword() {
if (!temporaryPassword.value) return;
await navigator.clipboard.writeText(temporaryPassword.value);
ElMessage.success("临时密码已复制");
}
function clearTemporaryPassword() {
temporaryPassword.value = "";
}
function showPasswordResetBoundary() {
ElNotification.warning({
title: "安全边界",
message: "正式版不支持查看当前明文密码,只能重置并一次性查看临时密码。"
});
}
async function toggleStatus(row: Employee) {
if (!canUpdateEmployee.value) return;
const nextStatus: EmployeeStatus =
row.status === "ACTIVE" ? "INACTIVE" : "ACTIVE";
const action = nextStatus === "ACTIVE" ? "启用" : "停用";
try {
await ElMessageBox.confirm(
`确认${action}员工「${row.name}」?`,
"状态变更",
{
type: "warning",
confirmButtonText: action,
cancelButtonText: "取消"
}
);
await updateEmployeeStatus(row.id, nextStatus);
ElMessage.success(`${action}`);
fetchEmployees();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(getErrorMessage(error, "状态变更失败"));
}
}
}
async function removeEmployee(row: Employee) {
if (!canDeleteEmployee.value) return;
try {
await ElMessageBox.confirm(
`删除后员工「${row.name}」会被软删除并停用,确认继续?`,
"删除员工",
{
type: "warning",
confirmButtonText: "删除",
cancelButtonText: "取消"
}
);
await deleteEmployee(row.id);
ElMessage.success("员工已删除");
if (employees.value.length === 1 && query.page > 1) {
query.page -= 1;
}
fetchEmployees();
} catch (error) {
if (error !== "cancel") {
ElMessage.error(getErrorMessage(error, "删除员工失败"));
}
}
}
onMounted(async () => {
await Promise.all([fetchRoles(), fetchEmployees()]);
});
</script>
<template>
<section class="employee-page">
<div class="page-heading">
<div>
<p class="eyebrow">门店员工权限管理</p>
<h1>员工管理</h1>
</div>
<el-button
v-if="canCreateEmployee"
type="primary"
:icon="Plus"
@click="openCreateDialog"
>
新增员工
</el-button>
</div>
<div class="summary-strip">
<div class="summary-item">
<span>总员工</span>
<strong>{{ pagination.total }}</strong>
</div>
<div class="summary-item">
<span>当前页启用</span>
<strong>{{ activeCount }}</strong>
</div>
<div class="summary-item">
<span>当前页停用</span>
<strong>{{ inactiveCount }}</strong>
</div>
<div class="summary-item">
<span>角色数量</span>
<strong>{{ roles.length }}</strong>
</div>
</div>
<div class="toolbar">
<el-select
v-if="canViewAllEmployees"
v-model="query.storeId"
clearable
filterable
placeholder="全部门店"
class="toolbar-control"
:loading="storeLoading"
@change="handleSearch"
@visible-change="handleStoreDropdownVisible"
>
<el-option
v-for="store in stores"
:key="store.id"
:label="getStoreOptionLabel(store)"
:value="store.id"
/>
</el-select>
<el-select
v-model="query.status"
clearable
placeholder="全部状态"
class="toolbar-control"
@change="handleSearch"
>
<el-option label="启用" value="ACTIVE" />
<el-option label="停用" value="INACTIVE" />
</el-select>
<el-input
v-model="query.keyword"
clearable
class="keyword-input"
placeholder="搜索姓名或手机号"
@clear="handleSearch"
@keyup.enter="handleSearch"
/>
<div class="toolbar-actions">
<el-button type="primary" :icon="Search" @click="handleSearch">
查询
</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
</div>
</div>
<div class="table-shell">
<el-table
v-loading="tableLoading"
:data="employees"
row-key="id"
stripe
class="employee-table"
>
<el-table-column prop="name" label="员工" min-width="160">
<template #default="{ row }">
<div class="employee-cell">
<strong>{{ row.name }}</strong>
<span>{{ row.phone }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="storeName" label="门店" min-width="170">
<template #default="{ row }">
<div class="store-cell">
<span>{{ row.storeName }}</span>
<el-tag
v-if="row.storeStatus === 'INACTIVE'"
size="small"
type="warning"
effect="light"
>
停用
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="角色" min-width="220">
<template #default="{ row }">
<div class="role-tags">
<el-tag
v-for="role in row.roles"
:key="role.id"
effect="plain"
size="small"
>
{{ role.name }}
</el-tag>
<span v-if="row.roles.length === 0" class="muted">未绑定</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" min-width="190">
<template #default="{ row }">
<div class="status-tags">
<el-tag
v-for="tag in resolveStatusTags(row)"
:key="tag.code"
:type="getEmployeeStatusTagType(tag)"
effect="light"
size="small"
>
{{ tag.label }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="180">
<template #default="{ row }">
<span>{{ row.remark || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="更新时间" width="180">
<template #default="{ row }">
{{ formatTime(row.updatedAt) }}
</template>
</el-table-column>
<el-table-column
v-if="canOperateEmployee"
label="操作"
width="420"
fixed="right"
>
<template #default="{ row }">
<el-button
v-if="canUpdateEmployee"
link
type="primary"
:icon="EditPen"
@click="openEditDialog(row)"
>
编辑
</el-button>
<el-button
v-if="canUpdateEmployee"
link
:type="row.status === 'ACTIVE' ? 'warning' : 'success'"
:icon="row.status === 'ACTIVE' ? CircleClose : CircleCheck"
@click="toggleStatus(row)"
>
{{ row.status === "ACTIVE" ? "停用" : "启用" }}
</el-button>
<el-button
v-if="canResetCredential"
link
type="warning"
:icon="Key"
@click="openPasswordDialog(row)"
>
重置密码
</el-button>
<el-button
v-if="canDeleteEmployee"
link
type="danger"
:icon="Delete"
@click="removeEmployee(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-row">
<span
> {{ pagination.total }} {{ pagination.totalPages }} </span
>
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
background
layout="sizes, prev, pager, next"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="560px"
destroy-on-close
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="employee-form"
>
<el-form-item label="所属门店" prop="storeId">
<el-select
v-model="form.storeId"
filterable
placeholder="请选择门店"
class="full-width"
:loading="storeLoading"
@visible-change="handleStoreDropdownVisible"
>
<el-option
v-for="store in stores"
:key="store.id"
:label="getStoreOptionLabel(store)"
:value="store.id"
:disabled="
store.status === 'INACTIVE' && store.id !== form.storeId
"
/>
</el-select>
</el-form-item>
<div class="form-grid">
<el-form-item label="员工姓名" prop="name">
<el-input v-model="form.name" maxlength="50" placeholder="请输入" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="form.phone"
maxlength="11"
placeholder="请输入 11 位手机号"
/>
</el-form-item>
</div>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio-button label="ACTIVE">启用</el-radio-button>
<el-radio-button label="INACTIVE">停用</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="角色" prop="roleIds">
<el-select
v-model="form.roleIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="请选择角色"
class="full-width"
:loading="roleLoading"
>
<el-option
v-for="role in roles"
:key="role.id"
:label="`${role.name}${role.code}`"
:value="role.id"
/>
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="form.remark"
type="textarea"
maxlength="500"
show-word-limit
:rows="4"
placeholder="可填写班次、权限说明或其他备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">
保存
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="passwordDialogVisible"
title="重置员工密码"
width="460px"
destroy-on-close
>
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-position="top"
>
<el-form-item label="员工">
<el-input v-model="passwordForm.employeeName" disabled />
</el-form-item>
<el-alert
type="warning"
show-icon
:closable="false"
class="password-alert"
title="不会展示员工当前密码。提交后仅在下一步弹窗显示一次临时密码。"
/>
<el-form-item label="重置原因" prop="reason">
<el-input
v-model="passwordForm.reason"
type="textarea"
maxlength="255"
show-word-limit
:rows="4"
placeholder="请填写重置原因,用于凭据审计"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="passwordDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="passwordSubmitLoading"
@click="submitPasswordForm"
>
重置并生成临时密码
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="temporaryPasswordVisible"
title="一次性临时密码"
width="460px"
destroy-on-close
@closed="clearTemporaryPassword"
>
<el-alert
type="warning"
show-icon
:closable="false"
class="password-alert"
title="关闭弹窗后前端会清空临时密码,请立即复制并通过安全渠道交付员工。"
/>
<el-input
:model-value="temporaryPassword"
readonly
class="temporary-password"
@focus="showPasswordResetBoundary"
/>
<template #footer>
<el-button @click="temporaryPasswordVisible = false">关闭</el-button>
<el-button
type="primary"
:icon="CopyDocument"
@click="copyTemporaryPassword"
>
复制临时密码
</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped lang="scss">
.employee-page {
min-height: 100%;
padding: 20px;
background: #f6f8fb;
}
.page-heading {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h1 {
margin: 4px 0 0;
font-size: 24px;
font-weight: 650;
color: #111827;
letter-spacing: 0;
}
}
.eyebrow {
margin: 0;
font-size: 13px;
color: #64748b;
}
.summary-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-item {
padding: 14px 16px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
span {
display: block;
margin-bottom: 8px;
font-size: 13px;
color: #64748b;
}
strong {
font-size: 24px;
font-weight: 700;
color: #111827;
letter-spacing: 0;
}
}
.toolbar,
.table-shell {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 14px;
margin-bottom: 16px;
}
.toolbar-control {
width: 180px;
}
.keyword-input {
width: 240px;
}
.toolbar-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.table-shell {
padding: 8px 0 14px;
overflow: hidden;
}
.employee-table {
width: 100%;
}
.employee-cell {
display: flex;
flex-direction: column;
gap: 3px;
strong {
font-weight: 650;
color: #111827;
}
span {
font-size: 12px;
color: #64748b;
}
}
.role-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.store-cell,
.status-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.muted {
color: #94a3b8;
}
.pagination-row {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
padding: 14px 16px 0;
font-size: 13px;
color: #64748b;
}
.employee-form {
padding-top: 4px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.full-width {
width: 100%;
}
.password-alert {
margin-bottom: 16px;
}
.temporary-password {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
@media (width <= 760px) {
.employee-page {
padding: 12px;
}
.page-heading {
flex-direction: column;
align-items: flex-start;
}
.summary-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.toolbar-control,
.keyword-input,
.toolbar-actions {
width: 100%;
margin-left: 0;
}
.toolbar-actions {
justify-content: flex-end;
}
.pagination-row {
flex-direction: column;
align-items: flex-start;
}
.form-grid {
grid-template-columns: 1fr;
}
}
</style>