Enhance Form Management and Process Handling

- Updated the form management interface to improve the search and filter functionality, including new filters for category and group, enhancing user experience and organization.
- Redesigned the forms display to utilize a grid layout, providing a more visually appealing and user-friendly interface for form management.
- Introduced loading states and improved error handling for process publishing and unpublishing, ensuring better feedback for users during these actions.
- Added functionality to prevent deletion of published processes, guiding users to unpublish first, thereby improving data integrity and user guidance.
- Enhanced the process store with a new unpublish method, allowing for better management of process states and updates to the UI accordingly.
This commit is contained in:
Md Afiq Iskandar 2025-08-05 17:28:11 +08:00
parent c43d9b6849
commit 0c93e93460
4 changed files with 351 additions and 200 deletions

View File

@ -47,65 +47,71 @@
<!-- Main Content Area --> <!-- Main Content Area -->
<div class="flex-1 p-6 overflow-auto"> <div class="flex-1 p-6 overflow-auto">
<!-- Filters and Search --> <!-- Filters and Search -->
<div class="mb-6 flex flex-col sm:flex-row gap-4"> <div class="mb-6 bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex-1 max-w-md"> <div class="flex flex-col lg:flex-row gap-4">
<FormKit <div class="flex-1 max-w-md">
v-model="searchQuery" <FormKit
type="text" v-model="searchQuery"
placeholder="Search forms by name or description..." type="text"
:classes="{ placeholder="Search forms by name or description..."
outer: 'mb-0', :classes="{
wrapper: 'relative', outer: 'mb-0',
inner: 'relative', wrapper: 'relative',
input: 'w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500', inner: 'relative',
prefixIcon: 'absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none' input: 'w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
}" prefixIcon: 'absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 pointer-events-none'
> }"
<template #prefixIcon> >
<Icon name="material-symbols:search" class="text-lg" /> <template #prefixIcon>
</template> <Icon name="material-symbols:search" class="text-lg" />
</FormKit> </template>
</FormKit>
</div>
<div class="min-w-48">
<FormKit
v-model="selectedCategory"
type="select"
:options="categoryOptions"
placeholder="Filter by category"
:classes="{
outer: 'mb-0',
input: 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white'
}"
/>
</div>
<div class="min-w-48">
<FormKit
v-model="selectedGroup"
type="select"
:options="groupOptions"
placeholder="Filter by group"
:classes="{
outer: 'mb-0',
input: 'w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white'
}"
/>
</div>
<div class="flex gap-2">
<RsButton
@click="clearFilters"
variant="secondary"
size="sm"
:disabled="loading"
v-if="hasActiveFilters"
>
<Icon name="material-symbols:filter-alt-off" class="mr-1" />
Clear
</RsButton>
<RsButton @click="loadForms" variant="tertiary" size="sm" :disabled="loading">
<Icon name="material-symbols:refresh" class="mr-1" />
Refresh
</RsButton>
</div>
</div> </div>
<div class="flex gap-2">
<!-- Category Filter -->
<div class="relative">
<FormKit
v-model="selectedCategory"
type="select"
placeholder="All Categories"
:options="categoryOptions"
:classes="{
outer: 'mb-0',
input: 'min-w-[140px] text-sm'
}"
/>
</div>
<!-- Group Filter -->
<div class="relative">
<FormKit
v-model="selectedGroup"
type="select"
placeholder="All Groups"
:options="groupOptions"
:classes="{
outer: 'mb-0',
input: 'min-w-[140px] text-sm'
}"
/>
</div>
<RsButton @click="clearFilters" variant="secondary" size="sm" :disabled="loading" v-if="hasActiveFilters">
<Icon name="material-symbols:filter-alt-off" class="mr-1" />
Clear Filters
</RsButton>
<RsButton @click="loadForms" variant="tertiary" size="sm" :disabled="loading">
<Icon name="material-symbols:refresh" class="mr-1" />
Refresh
</RsButton>
</div>
</div> </div>
<!-- Loading State --> <!-- Loading State -->
@ -116,133 +122,115 @@
</div> </div>
</div> </div>
<!-- Forms Table --> <!-- Forms Grid -->
<div v-else class="bg-white shadow-md rounded-lg overflow-hidden"> <div v-else-if="filteredForms.length > 0" class="grid gap-4">
<table class="w-full table-auto"> <div
<thead class="bg-gray-50 border-b"> v-for="form in filteredForms"
<tr> :key="form.id"
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th> class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th> >
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th> <div class="flex items-start justify-between">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Group</th> <div class="flex-1">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th> <div class="flex items-center gap-3 mb-2">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Updated</th> <h3 class="text-lg font-semibold text-gray-900">{{ form.name || 'Untitled Form' }}</h3>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> <div class="flex gap-2">
</tr> <RsBadge v-if="form.category" :variant="getCategoryColor(form.category)" size="sm">
</thead> {{ form.category }}
<tbody class="divide-y divide-gray-200"> </RsBadge>
<tr v-if="filteredForms.length === 0"> <RsBadge v-if="form.group" variant="secondary" size="sm">
<td colspan="7" class="px-6 py-12 text-center text-gray-500"> {{ form.group }}
<div class="flex flex-col items-center"> </RsBadge>
<Icon name="material-symbols:description-outline" class="w-12 h-12 text-gray-300 mb-2" />
<p class="text-lg font-medium mb-1">
{{ searchQuery ? 'No forms match your search' : 'No forms found' }}
</p>
<p class="text-sm mb-2">
{{ searchQuery ? 'Try adjusting your search terms' : 'Create your first form to get started' }}
</p>
<!-- Show current search if any -->
<div v-if="searchQuery" class="text-xs text-gray-400 mb-4">
<div>Search: "{{ searchQuery }}"</div>
</div>
<div class="flex gap-2">
<RsButton v-if="hasActiveFilters" @click="clearFilters" variant="secondary" size="sm">
<Icon name="material-symbols:filter-alt-off" class="mr-1" />
Clear Filters
</RsButton>
<RsButton v-if="!hasActiveFilters" @click="createNewForm" variant="primary" size="sm">
<Icon name="material-symbols:add" class="mr-1" />
Create New Form
</RsButton>
</div>
</div> </div>
</td> </div>
</tr>
<tr v-for="form in filteredForms" :key="form.id" class="hover:bg-gray-50"> <p class="text-gray-600 mb-3">
<td class="px-6 py-4 whitespace-nowrap"> {{ form.description || 'No description available' }}
<div class="font-medium text-gray-900">{{ form.name || 'Untitled Form' }}</div> </p>
<div class="text-sm text-gray-500">ID: {{ form.id }}</div>
</td> <div class="flex items-center gap-6 text-sm text-gray-500">
<td class="px-6 py-4"> <div class="flex items-center gap-1">
<div class="text-sm text-gray-700 max-w-xs"> <Icon name="material-symbols:fingerprint" class="w-4 h-4" />
<div ID: {{ form.id }}
v-if="form.description && form.description.trim()"
class="line-clamp-2 cursor-help"
:title="form.description.length > 100 ? form.description : ''"
>
{{ form.description }}
</div>
<div v-else class="text-gray-400 italic">
No description
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div v-if="form.category" class="text-sm">
<RsBadge :variant="getCategoryColor(form.category)" size="sm">
{{ form.category }}
</RsBadge>
</div>
<div v-else class="text-sm text-gray-400 italic">No category</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div v-if="form.group" class="text-sm">
<RsBadge variant="secondary" size="sm">
{{ form.group }}
</RsBadge>
</div>
<div v-else class="text-sm text-gray-400 italic">No group</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500">{{ formatDate(form.createdAt) }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500">{{ formatDate(form.updatedAt) }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex space-x-2 justify-end">
<button
@click="editForm(form.id)"
class="p-1 text-blue-600 hover:text-blue-900 hover:bg-blue-50 rounded"
title="Edit Form"
:disabled="loading"
>
<Icon name="material-symbols:edit" class="text-lg" />
</button>
<button
@click="duplicateForm(form)"
class="p-1 text-indigo-600 hover:text-indigo-900 hover:bg-indigo-50 rounded"
title="Duplicate Form"
:disabled="loading"
>
<Icon name="material-symbols:content-copy" class="text-lg" />
</button>
<button
@click="exportForm(form)"
class="p-1 text-green-600 hover:text-green-900 hover:bg-green-50 rounded"
title="Export Form as JSON"
:disabled="loading"
>
<Icon name="material-symbols:download" class="text-lg" />
</button>
<button
@click="confirmDelete(form.id)"
class="p-1 text-red-600 hover:text-red-900 hover:bg-red-50 rounded"
title="Delete Form"
:disabled="loading"
>
<Icon name="material-symbols:delete" class="text-lg" />
</button>
</div> </div>
</td> <div class="flex items-center gap-1">
</tr> <Icon name="material-symbols:calendar-add-on-outline" class="w-4 h-4" />
</tbody> Created {{ formatDate(form.createdAt) }}
</table> </div>
<div class="flex items-center gap-1">
<Icon name="material-symbols:schedule" class="w-4 h-4" />
Updated {{ formatDate(form.updatedAt) }}
</div>
</div>
</div>
<div class="flex items-center gap-2 ml-4">
<button
@click="editForm(form.id)"
class="p-2 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-lg transition-colors"
title="Edit Form"
:disabled="loading"
>
<Icon name="material-symbols:edit" class="text-lg" />
</button>
<button
@click="duplicateForm(form)"
class="p-2 text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded-lg transition-colors"
title="Duplicate Form"
:disabled="loading"
>
<Icon name="material-symbols:content-copy" class="text-lg" />
</button>
<button
@click="exportForm(form)"
class="p-2 text-green-600 hover:text-green-800 hover:bg-green-50 rounded-lg transition-colors"
title="Export Form as JSON"
:disabled="loading"
>
<Icon name="material-symbols:download" class="text-lg" />
</button>
<button
@click="confirmDelete(form.id)"
class="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition-colors"
title="Delete Form"
:disabled="loading"
>
<Icon name="material-symbols:delete" class="text-lg" />
</button>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
<Icon name="material-symbols:description-outline" class="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 class="text-lg font-medium text-gray-900 mb-2">
{{ hasActiveFilters ? 'No forms match your filters' : 'No forms found' }}
</h3>
<p class="text-gray-600 mb-6">
{{ hasActiveFilters ? 'Try adjusting your search or filter criteria' : 'Create your first form to get started' }}
</p>
<!-- Show current filters if any -->
<div v-if="hasActiveFilters" class="text-sm text-gray-400 mb-6 space-y-1">
<div v-if="searchQuery">Search: "{{ searchQuery }}"</div>
<div v-if="selectedCategory">Category: {{ selectedCategory }}</div>
<div v-if="selectedGroup">Group: {{ selectedGroup }}</div>
</div>
<div class="flex justify-center gap-3">
<RsButton v-if="hasActiveFilters" @click="clearFilters" variant="secondary">
<Icon name="material-symbols:filter-alt-off" class="mr-2" />
Clear Filters
</RsButton>
<RsButton v-if="!hasActiveFilters" @click="createNewForm" variant="primary">
<Icon name="material-symbols:add" class="mr-2" />
Create New Form
</RsButton>
</div>
</div> </div>
<!-- Global Loading Overlay --> <!-- Global Loading Overlay -->
@ -541,18 +529,17 @@ const duplicateForm = async (form) => {
loading.value = true; loading.value = true;
const newName = `${form.name || 'Form'} (Copy)`; const newName = `${form.name || 'Form'} (Copy)`;
// Create a copy of the form // Load the original form first
const duplicatedForm = {
...form,
id: null, // Will get new ID when saved
name: newName,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
// Load the original form, duplicate it, and save as new
await formStore.loadForm(form.id); await formStore.loadForm(form.id);
// Clear the current form ID to ensure a new form is created
formStore.currentFormId = null;
// Update the form name for the duplicate
formStore.setFormName(newName); formStore.setFormName(newName);
// Mark as having unsaved changes and save as new form
formStore.hasUnsavedChanges = true;
await formStore.saveForm(); await formStore.saveForm();
// Refresh the form list // Refresh the form list
@ -923,9 +910,17 @@ const closeImportModal = () => {
</script> </script>
<style scoped> <style scoped>
/* Custom styles for better UX */ /* Custom transitions and animations */
.hover\:bg-gray-50:hover { .transition-colors {
transition: background-color 0.15s ease-in-out; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.transition-shadow {
transition-property: box-shadow;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
} }
button:disabled { button:disabled {
@ -933,6 +928,11 @@ button:disabled {
cursor: not-allowed; cursor: not-allowed;
} }
/* Glassmorphism effect for cards */
.bg-white {
backdrop-filter: blur(10px);
}
/* Line clamp utility for description text */ /* Line clamp utility for description text */
.line-clamp-2 { .line-clamp-2 {
display: -webkit-box; display: -webkit-box;

View File

@ -283,8 +283,24 @@ const publishProcess = async (processId) => {
loading.value = true; loading.value = true;
await processStore.publishProcess(processId); await processStore.publishProcess(processId);
await loadProcesses(); await loadProcesses();
toast.success('Process published successfully');
} catch (error) { } catch (error) {
console.error('Error publishing process:', error); console.error('Error publishing process:', error);
toast.error('Failed to publish process: ' + (error.message || 'Unknown error'));
} finally {
loading.value = false;
}
};
const unpublishProcess = async (processId) => {
try {
loading.value = true;
await processStore.unpublishProcess(processId);
await loadProcesses();
toast.success('Process unpublished successfully');
} catch (error) {
console.error('Error unpublishing process:', error);
toast.error('Failed to unpublish process: ' + (error.message || 'Unknown error'));
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -303,6 +319,14 @@ const restoreProcess = async (processId) => {
}; };
const confirmDelete = (processId) => { const confirmDelete = (processId) => {
const process = processStore.processes.find(p => p.id === processId);
// If process is published, show a message instead of allowing deletion
if (process && process.status === 'published') {
toast.warning('Please unpublish the process first before deleting it.');
return;
}
processToDelete.value = processId; processToDelete.value = processId;
showDeleteConfirm.value = true; showDeleteConfirm.value = true;
}; };
@ -710,13 +734,23 @@ const copyWorkflowLink = async (processId) => {
<button <button
v-if="process.status === 'draft'" v-if="process.status === 'draft'"
@click="publishProcess(process.id)" @click="publishProcess(process.id)"
class="p-2 text-green-600 hover:text-green-800 hover:bg-green-50 rounded-lg transition-colors" class="p-2 text-green-600 hover:text-green-800 hover:bg-green-50 rounded-lg transition-colors"
title="Publish Process" title="Publish Process"
:disabled="loading" :disabled="loading"
> >
<Icon name="material-symbols:publish" class="text-lg" /> <Icon name="material-symbols:publish" class="text-lg" />
</button> </button>
<button
v-if="process.status === 'published'"
@click="unpublishProcess(process.id)"
class="p-2 text-orange-600 hover:text-orange-800 hover:bg-orange-50 rounded-lg transition-colors"
title="Unpublish Process (Move to Draft)"
:disabled="loading"
>
<Icon name="material-symbols:unpublished" class="text-lg" />
</button>
<button <button
@click="duplicateProcess(process)" @click="duplicateProcess(process)"
class="p-2 text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded-lg transition-colors" class="p-2 text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded-lg transition-colors"
@ -728,8 +762,8 @@ const copyWorkflowLink = async (processId) => {
<button <button
@click="confirmDelete(process.id)" @click="confirmDelete(process.id)"
class="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition-colors" class="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition-colors"
title="Delete Process" :title="process.status === 'published' ? 'Unpublish first to delete this process' : 'Delete Process'"
:disabled="loading || process.status === 'published'" :disabled="loading || process.status === 'published'"
> >
<Icon name="material-symbols:delete" class="text-lg" /> <Icon name="material-symbols:delete" class="text-lg" />
@ -790,7 +824,7 @@ const copyWorkflowLink = async (processId) => {
This process will be moved to trash but not permanently deleted. You can restore it later from the "Deleted" filter if needed. This process will be moved to trash but not permanently deleted. You can restore it later from the "Deleted" filter if needed.
</p> </p>
<p class="text-gray-500 text-xs mt-2"> <p class="text-gray-500 text-xs mt-2">
Note: Published processes cannot be deleted and must be unpublished first. Note: To delete a published process, first unpublish it using the orange unpublish button.
</p> </p>
</div> </div>
</div> </div>

View File

@ -0,0 +1,84 @@
import { PrismaClient } from '@prisma/client';
// Initialize Prisma client
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
try {
// Get the process ID from the route parameter
const processId = getRouterParam(event, 'id');
if (!processId) {
return {
success: false,
error: 'Process ID is required'
};
}
// Check if the ID is a UUID or numeric ID
const isUUID = processId.length === 36 && processId.includes('-');
// First, get the current process to validate it can be unpublished
const currentProcess = await prisma.process.findFirst({
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) }
});
if (!currentProcess) {
return {
success: false,
error: 'Process not found'
};
}
// Check if the process is currently published
if (currentProcess.processStatus !== 'published') {
return {
success: false,
error: 'Process is not currently published'
};
}
// Update the process status to draft
const unpublishedProcess = await prisma.process.update({
where: isUUID
? { processUUID: processId }
: { processID: parseInt(processId) },
data: {
processStatus: 'draft'
},
include: {
creator: {
select: {
userID: true,
userFullName: true,
userUsername: true
}
}
}
});
return {
success: true,
message: 'Process unpublished successfully',
process: unpublishedProcess
};
} catch (error) {
console.error('Error unpublishing process:', error);
// Handle specific Prisma errors
if (error.code === 'P2025') {
return {
success: false,
error: 'Process not found'
};
}
return {
success: false,
error: 'Failed to unpublish process',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
};
}
});

View File

@ -633,6 +633,39 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
} }
}, },
/**
* Unpublish a process (change status from published to draft)
*/
async unpublishProcess(processId) {
try {
const response = await $fetch(`/api/process/${processId}/unpublish`, {
method: 'POST'
});
if (response.success) {
// Update local state if process exists in the array
const process = this.processes.find(p => p.id === processId);
if (process) {
process.status = 'draft';
process.updatedAt = response.process.processModifiedDate;
}
// Update current process if it's the same one
if (this.currentProcess && this.currentProcess.id === processId) {
this.currentProcess.status = 'draft';
this.currentProcess.updatedAt = response.process.processModifiedDate;
}
return true;
} else {
throw new Error(response.error || 'Failed to unpublish process');
}
} catch (error) {
console.error('Error unpublishing process:', error);
throw error;
}
},
/** /**
* Duplicate a process * Duplicate a process
*/ */