feat: progress

This commit is contained in:
tonyaellie
2025-12-27 22:24:38 +00:00
parent 89692b4603
commit dc7f06210c
3 changed files with 293 additions and 236 deletions

View File

@@ -1,126 +1,121 @@
<script setup lang="ts">
import { reactive, watch, ref } from "vue";
import { reactive, ref, onMounted, onUnmounted } from "vue";
import type { User as MockUser, Collection as MockCollection } from "~/mock/collections";
import { api } from "~/mock/collections";
import { DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { DialogID } from "@/components/ui/dialog-provider/utils";
import { useDialog } from "@/components/ui/dialog-provider";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select";
import MdiClose from "~icons/mdi/close";
const props = defineProps<{
modelValue: boolean;
// editing matches the mock User shape: collections are {id, role} tuples
editing: MockUser | null;
collections: MockCollection[];
editingCollectionIds: string[];
newPassword: string;
}>();
// dialog provider
const { closeDialog, registerOpenDialogCallback } = useDialog();
const emit = defineEmits([
"update:modelValue",
"update:editing",
"update:editingCollectionIds",
"update:newPassword",
"save",
"cancel",
"collections-changed",
] as const);
// local collections snapshot used for checkbox list
const availableCollections = ref<MockCollection[]>(api.getCollections() as MockCollection[]);
const isNew = ref(true);
const localEditing = reactive<MockUser>(
props.editing
? { ...props.editing }
: { id: String(Date.now()), name: "", email: "", role: "user", password_set: false, collections: [] }
);
const localEditing = reactive<MockUser>({
id: String(Date.now()),
name: "",
email: "",
role: "user",
password_set: false,
collections: [],
});
const localCollectionIds = ref<string[]>([...(props.editingCollectionIds ?? [])]);
const localNewPassword = ref(props.newPassword ?? "");
const localCollectionIds = ref<string[]>([]);
const localNewPassword = ref("");
const newAddCollectionId = ref<string>("");
onMounted(() => {
const cleanup = registerOpenDialogCallback(DialogID.EditUser, params => {
// refresh available collections each time
availableCollections.value = api.getCollections() as MockCollection[];
if (params && (params as { userId?: string }).userId) {
const u = api.getUser(params.userId!);
if (u) {
Object.assign(localEditing, u as MockUser);
localCollectionIds.value = (u.collections ?? []).map(c => c.id);
isNew.value = false;
} else {
reset();
isNew.value = true;
}
} else {
// new user
reset();
isNew.value = true;
}
localNewPassword.value = "";
});
onUnmounted(cleanup);
});
type Membership = { id: string; role: "owner" | "admin" | "editor" | "viewer" };
watch(
() => props.editing,
v => {
if (v) Object.assign(localEditing, v);
else {
localEditing.id = String(Date.now());
localEditing.name = "";
localEditing.email = "";
localEditing.role = "user";
localEditing.password_set = false;
localEditing.collections = [];
}
},
{ immediate: true }
);
function getCollectionName(id: string) {
const found = availableCollections.value.find(c => c.id === id);
return found ? found.name : id;
}
watch(
() => props.editingCollectionIds,
v => {
localCollectionIds.value = [...(v ?? [])];
},
{ immediate: true }
);
watch(
() => props.newPassword,
v => (localNewPassword.value = v ?? ""),
{ immediate: true }
);
// localEditing will be set when dialog opens via registerOpenDialogCallback
function close() {
emit("update:modelValue", false);
emit("cancel");
reset();
closeDialog(DialogID.EditUser);
}
function onSave() {
// propagate changes back to parent
emit("update:editing", { ...localEditing });
emit("update:editingCollectionIds", [...localCollectionIds.value]);
emit("update:newPassword", localNewPassword.value);
emit("save");
emit("update:modelValue", false);
if (!localEditing.name.trim() || !localEditing.email.trim()) {
alert("Name and email are required");
return;
}
if (localNewPassword.value && localEditing) localEditing.password_set = true;
const existing = api.getUser(localEditing.id);
if (existing) {
const updated = {
...existing,
name: localEditing.name,
email: localEditing.email,
role: localEditing.role,
password_set: localEditing.password_set,
} as MockUser;
api.updateUser(updated);
} else {
const toCreate = { ...localEditing, collections: [] } as MockUser;
const created = api.addUser(toCreate);
localCollectionIds.value.forEach(id => api.addUserToCollection(created.id, id, "viewer"));
}
// close and signal caller to refresh
closeDialog(DialogID.EditUser, true);
reset();
}
function onCheckboxChange(e: Event, id: string) {
const checked = (e.target as HTMLInputElement).checked;
// if this user exists in the api, call the api to add/remove membership immediately
const existsInApi = !!api.getUser(localEditing.id);
if (!checked) {
// unchecking
if (existsInApi) {
// will confirm inside removeMembership
const ok = api.removeUserFromCollection(localEditing.id, id);
if (ok) {
// update local state to reflect api change
localCollectionIds.value = localCollectionIds.value.filter(x => x !== id);
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
}
return;
}
// not in API yet (new user) — just update local state
localCollectionIds.value = localCollectionIds.value.filter(x => x !== id);
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
return;
}
// checking
if (existsInApi) {
const mem = api.addUserToCollection(localEditing.id, id, "viewer");
if (mem) {
if (!localCollectionIds.value.includes(id)) localCollectionIds.value.push(id);
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
localEditing.collections.push(mem as Membership);
emit("update:editing", { ...api.getUser(localEditing.id) });
emit("update:editingCollectionIds", [...localCollectionIds.value]);
emit("collections-changed");
}
return;
}
// new user — just add locally
if (!localCollectionIds.value.includes(id)) localCollectionIds.value.push(id);
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
localEditing.collections.push({ id, role: "viewer" });
function reset() {
localEditing.id = String(Date.now());
localEditing.name = "";
localEditing.email = "";
localEditing.role = "user";
localEditing.password_set = false;
localEditing.collections = [];
localCollectionIds.value = [];
localNewPassword.value = "";
}
function removeMembership(id: string) {
@@ -139,9 +134,8 @@
if (ok) {
localCollectionIds.value = localCollectionIds.value.filter(x => x !== id);
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
emit("update:editing", { ...api.getUser(localEditing.id) });
emit("update:editingCollectionIds", [...localCollectionIds.value]);
emit("collections-changed");
const refreshed = api.getUser(localEditing.id);
if (refreshed) Object.assign(localEditing, refreshed as MockUser);
}
return;
}
@@ -150,51 +144,144 @@
localCollectionIds.value = localCollectionIds.value.filter(x => x !== id);
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
}
function addMembership(id: string) {
if (!id) return;
const existsInApi = !!api.getUser(localEditing.id);
if (existsInApi) {
const mem = api.addUserToCollection(localEditing.id, id, "viewer");
if (mem) {
if (!localCollectionIds.value.includes(id)) localCollectionIds.value.push(id);
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
localEditing.collections.push(mem as Membership);
const refreshed = api.getUser(localEditing.id);
if (refreshed) Object.assign(localEditing, refreshed as MockUser);
}
return;
}
// new user — add locally
if (!localCollectionIds.value.includes(id)) localCollectionIds.value.push(id);
localEditing.collections = (localEditing.collections ?? []).filter((x: Membership) => x.id !== id);
localEditing.collections.push({ id, role: "viewer" });
}
function updateMembershipRole(id: string, role: Membership["role"]) {
const existsInApi = !!api.getUser(localEditing.id);
if (existsInApi) {
// best-effort: remove then re-add with new role if API doesn't expose direct update
api.removeUserFromCollection(localEditing.id, id);
const mem = api.addUserToCollection(localEditing.id, id, role);
if (mem) {
const refreshed = api.getUser(localEditing.id);
if (refreshed) Object.assign(localEditing, refreshed as MockUser);
localCollectionIds.value = (localEditing.collections ?? []).map((c: Membership) => c.id);
}
return;
}
// local-only
const existing = (localEditing.collections ?? []) as Membership[];
const found = existing.find(x => x.id === id);
if (found) found.role = role;
}
</script>
<template>
<DialogContent v-if="props.modelValue">
<DialogHeader>
<DialogTitle>{{ props.editing ? "Edit User" : "Add User" }}</DialogTitle>
<DialogDescription> Manage user details and collection memberships. </DialogDescription>
</DialogHeader>
<Dialog :dialog-id="DialogID.EditUser">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ isNew ? "Add User" : "Edit User" }}</DialogTitle>
<DialogDescription>Manage user details and collection memberships.</DialogDescription>
</DialogHeader>
<div class="grid gap-3">
<label class="block">
<div class="mb-1 text-sm">Name</div>
<Input v-model="localEditing.name" />
</label>
<form class="flex flex-col gap-3" @submit.prevent="onSave">
<label class="block">
<div class="mb-1 text-sm">Name</div>
<Input v-model="localEditing.name" />
</label>
<label class="block">
<div class="mb-1 text-sm">Email</div>
<Input v-model="localEditing.email" />
</label>
<label class="block">
<div class="mb-1 text-sm">Email</div>
<Input v-model="localEditing.email" />
</label>
<label class="block">
<div class="mb-1 text-sm">Password</div>
<Input v-model="localNewPassword" type="password" placeholder="Leave blank to keep" />
</label>
<label class="block">
<div class="mb-1 text-sm">Password</div>
<Input v-model="localNewPassword" type="password" placeholder="Leave blank to keep" />
</label>
<div>
<div class="mb-1 text-sm">Collections</div>
<div class="flex flex-wrap gap-2">
<label v-for="c in props.collections" :key="c.id" class="inline-flex items-center gap-2">
<input
type="checkbox"
:value="c.id"
:checked="localCollectionIds.includes(c.id)"
@change="onCheckboxChange($event, c.id)"
/>
<Badge class="whitespace-nowrap">{{ c.name }}</Badge>
<button type="button" class="ml-2 text-destructive" @click.prevent="removeMembership(c.id)">×</button>
</label>
<div>
<div class="mb-1 text-sm">Collections</div>
<div class="flex flex-col gap-3">
<div
v-for="m in localEditing.collections ?? []"
:key="m.id"
class="flex items-center justify-between rounded-lg border py-1 pl-3 pr-1"
>
<div class="text-lg font-medium">
<Badge>
{{ getCollectionName(m.id) }}
</Badge>
</div>
<div class="flex items-center gap-3">
<Select v-model="m.role" @update:model-value="val => updateMembershipRole(m.id, val)">
<SelectTrigger class="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="owner">Owner</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="editor">Editor</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
<Button
variant="destructive"
size="icon"
class="ml-2"
:title="$t ? $t('global.remove') : 'Remove'"
@click.prevent="removeMembership(m.id)"
>
<MdiClose class="size-4" />
</Button>
</div>
</div>
<div class="mt-2 flex items-center gap-2">
<Select v-model="newAddCollectionId">
<SelectTrigger class="flex-1">
<SelectValue placeholder="Select collection to add" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="c in availableCollections.filter(c => !localCollectionIds.includes(c.id))"
:key="c.id"
:value="c.id"
>
{{ c.name }}
</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
class="ml-2 w-10 px-0"
variant="default"
size="lg"
:disabled="!newAddCollectionId"
@click="addMembership(newAddCollectionId)"
>
+
</Button>
</div>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="close">Cancel</Button>
<Button @click="onSave">Save</Button>
</DialogFooter>
</DialogContent>
<DialogFooter>
<Button variant="outline" type="button" @click="close">Cancel</Button>
<Button type="submit">Save</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@@ -30,6 +30,7 @@ export enum DialogID {
UpdateLabel = "update-label",
UpdateLocation = "update-location",
CreateInvite = "create-invite",
EditUser = "edit-user",
UpdateTemplate = "update-template",
ItemChangeDetails = "item-table-updater",
}
@@ -57,6 +58,7 @@ export type DialogParamsMap = {
attachmentId: string;
};
[DialogID.CreateItem]?: { product?: BarcodeProduct };
[DialogID.EditUser]?: { userId?: string };
[DialogID.ProductImport]?: { barcode?: string };
[DialogID.EditMaintenance]:
| { type: "create"; itemId: string | string[] }
@@ -78,6 +80,7 @@ export type DialogResultMap = {
[DialogID.EditMaintenance]?: boolean;
[DialogID.CreateInvite]?: boolean;
[DialogID.ItemChangeDetails]?: boolean;
[DialogID.EditUser]?: boolean;
};
/** Helpers to split IDs by requirement */

View File

@@ -13,10 +13,15 @@
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
import MdiPencil from "~icons/mdi/pencil";
import MdiDelete from "~icons/mdi/delete";
import MdiCheck from "~icons/mdi/check";
import MdiClose from "~icons/mdi/close";
// import MdiOpenInNew from "~icons/mdi/open-in-new";
// Badge component for collections display
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import UserFormDialog from "@/components/Admin/UserFormDialog.vue";
import { useDialog } from "@/components/ui/dialog-provider";
import { DialogID } from "@/components/ui/dialog-provider/utils";
import { api, type Collection as MockCollection, type User } from "~/mock/collections";
@@ -35,10 +40,7 @@
});
});
const editing = ref<User | null>(null);
const showForm = ref(false);
const newPassword = ref("");
const editingCollectionIds = ref<string[]>([]);
const { openDialog } = useDialog();
const confirm = useConfirm();
const { t } = useI18n();
@@ -55,61 +57,26 @@
}
function openAdd() {
editing.value = { id: String(Date.now()), name: "", email: "", role: "user", password_set: false, collections: [] };
newPassword.value = "";
editingCollectionIds.value = [];
showForm.value = true;
openDialog(DialogID.EditUser, {
onClose: result => {
if (result) {
users.value = api.getUsers();
collections.value = api.getCollections();
}
},
});
}
function openEdit(u: User) {
editing.value = { ...u };
editingCollectionIds.value = (u.collections ?? []).map(c => c.id);
newPassword.value = "";
showForm.value = true;
}
function saveUser() {
if (!editing.value) return;
// basic validation
if (!editing.value.name.trim() || !editing.value.email.trim()) {
// keep UX simple: alert for now
// Replace with a nicer notification component when available
alert("Name and email are required");
return;
}
// apply password flag if new password was set locally
if (newPassword.value && editing.value) editing.value.password_set = true;
const existing = api.getUser(editing.value.id);
if (existing) {
// update only scalar fields; collections are managed via the add/remove API
const updated = {
...existing,
name: editing.value.name,
email: editing.value.email,
role: editing.value.role,
password_set: editing.value.password_set,
} as User;
api.updateUser(updated);
} else {
// create user without collections first, then add memberships
const toCreate = { ...editing.value, collections: [] } as User;
const created = api.addUser(toCreate);
editingCollectionIds.value.forEach(id => api.addUserToCollection(created.id, id, "viewer"));
}
// refresh local cache
users.value = api.getUsers();
editing.value = null;
showForm.value = false;
// TODO: call backend API to persist changes when available
}
function cancelForm() {
editing.value = null;
showForm.value = false;
openDialog(DialogID.EditUser, {
params: { userId: u.id },
onClose: result => {
if (result) {
users.value = api.getUsers();
collections.value = api.getCollections();
}
},
});
}
async function confirmDelete(u: User) {
@@ -130,17 +97,13 @@
return col ? col.name : id;
}
function onUpdateEditing(val: User | null) {
editing.value = val;
function roleVariant(role: string | undefined) {
if (role === "owner") return "default";
if (role === "admin") return "secondary";
return "outline";
}
function onUpdateEditingCollectionIds(val: string[]) {
editingCollectionIds.value = val;
}
function onUpdateNewPassword(val: string) {
newPassword.value = val;
}
// dialog handles editing state now via dialog provider
</script>
<template>
@@ -156,12 +119,12 @@
<Table class="w-full">
<TableHeader>
<TableRow>
<TableHead>{{ t("global.name") }}</TableHead>
<TableHead>{{ t("global.email") }}</TableHead>
<TableHead>Is Admin</TableHead>
<TableHead>Collections</TableHead>
<TableHead class="w-32 text-center">Auth</TableHead>
<TableHead class="w-40 text-center">{{ t("global.details") }}</TableHead>
<TableHead class="min-w-[160px]">{{ t("global.name") }}</TableHead>
<TableHead class="min-w-[220px]">{{ t("global.email") }}</TableHead>
<TableHead class="min-w-[96px] text-center">Is Admin</TableHead>
<TableHead class="min-w-[220px]">Collections</TableHead>
<TableHead class="min-w-[96px] text-center">{{ t("global.details") }}</TableHead>
<TableHead class="w-40 text-center"></TableHead>
</TableRow>
</TableHeader>
@@ -170,24 +133,40 @@
<TableRow v-for="u in filtered" :key="u.id">
<TableCell>{{ u.name }}</TableCell>
<TableCell>{{ u.email }}</TableCell>
<TableCell class="text-center">
<span class="font-medium">{{ u.role === "admin" ? "Yes" : "No" }}</span>
<TableCell class="text-center align-middle">
<div class="flex size-full items-center justify-center font-medium">
<MdiCheck v-if="u.role === 'admin'" class="text-primary" />
<MdiClose v-else class="text-destructive" />
</div>
</TableCell>
<TableCell>
<div class="flex flex-wrap items-center gap-2">
<template v-if="u.collections && u.collections.length">
<Badge v-for="c in u.collections" :key="c.id" class="whitespace-nowrap"
>{{ collectionName(c.id) }}<span class="ml-1 text-xs opacity-60">({{ c.role }})</span></Badge
>
<TooltipProvider :delay-duration="0">
<template v-for="c in u.collections" :key="c.id">
<Tooltip>
<TooltipTrigger as-child>
<Badge class="whitespace-nowrap" :variant="roleVariant(c.role)">{{
collectionName(c.id)
}}</Badge>
</TooltipTrigger>
<TooltipContent>
<p class="text-sm">{{ c.role }}</p>
</TooltipContent>
</Tooltip>
</template>
</TooltipProvider>
</template>
<span v-else class="text-muted-foreground">-</span>
</div>
</TableCell>
<TableCell class="text-center">
<span>{{ authType(u) }}</span>
<TableCell class="text-center align-middle">
<div class="flex size-full items-center justify-center">
<span>{{ authType(u) }}</span>
</div>
</TableCell>
<TableCell class="text-right">
<div class="flex justify-end gap-2">
<TableCell class="text-right align-middle">
<div class="flex size-full items-center justify-end gap-2">
<Button size="icon" variant="outline" class="size-8" :title="t('global.edit')" @click="openEdit(u)">
<MdiPencil class="size-4" />
</Button>
@@ -215,18 +194,6 @@
</Card>
<!-- Add / Edit form modal (moved to component) -->
<UserFormDialog
v-model="showForm"
:editing="editing"
:collections="collections"
:editing-collection-ids="editingCollectionIds"
:new-password="newPassword"
@update:editing="onUpdateEditing"
@update:editing-collection-ids="onUpdateEditingCollectionIds"
@update:new-password="onUpdateNewPassword"
@save="saveUser"
@cancel="cancelForm"
@collections-changed="() => (collections = api.getCollections())"
/>
<UserFormDialog />
</BaseContainer>
</template>