ระบบรับรองแหล่งผลิต GAP พืช (Web Application)
1. Tech Stack & Architecture 2. โครงสร้างโปรเจกต์ (Project Structure) 3. Roles & Permissions 4. Authentication — SSO Flow 5. Router Configuration 6. Portal Page — หน้าเมนูระบบกลาพร้ง 7. Application Step Form (v-stepper) 8. Dashboard 9. Application St
Install in one line
CLI$ mfkvault install neatsarab-gap-design-systemRequires the MFKVault CLI. Prefer MCP?
Free to install — no account needed
Copy the command below and paste into your agent.
Instant access • No coding needed • No account needed
What you get in 5 minutes
- Full skill code ready to install
- Works with 1 AI agent
- Lifetime updates included
Description
# ระบบรับรองแหล่งผลิต GAP พืช (Web Application) ## สารบัญ (Table of Contents) 1. [Tech Stack & Architecture](#1-tech-stack--architecture) 2. [โครงสร้างโปรเจกต์ (Project Structure)](#2-โครงสร้างโปรเจกต์-project-structure) 3. [Roles & Permissions](#3-roles--permissions) 4. [Authentication — SSO Flow](#4-authentication--sso-flow) 5. [Router Configuration](#5-router-configuration) 6. [Portal Page — หน้าเมนูระบบกลาพร้ง](#6-portal-page--หน้าเมนูระบบกลาง) 7. [Application Step Form (v-stepper)](#7-application-step-form-v-stepper) 8. [Dashboard](#8-dashboard) 9. [Application State Flow](#9-application-state-flow) 10. [Inspection Module](#10-inspection-module) 11. [Certificate Module](#11-certificate-module) 12. [Application Store (Pinia)](#12-application-store-pinia) 13. [Sidebar Navigation](#13-sidebar-navigation) 14. [Vuetify Theme Configuration](#14-vuetify-theme-configuration) 15. [API Endpoints Summary](#15-api-endpoints-summary) 16. [Environment Variables](#16-environment-variables) 17. [Deployment & DevOps Notes](#17-deployment--devops-notes) --- ## 1. Tech Stack & Architecture |Layer |Technology | |------------------|------------------------------------------| |Frontend Framework|Vue 3 (Composition API + `<script setup>`)| |UI Library |Vuetify 3 | |Routing |Vue Router 4 | |State Management |Pinia | |Authentication |SSO (OAuth 2.0 / OpenID Connect) | |HTTP Client |Axios | |PDF Generation |html2pdf.js / jsPDF | |Build Tool |Vite | ----- ## 1. โครงสร้างโปรเจกต์ (Project Structure) ``` src/ ├── App.vue ├── main.ts ├── assets/ │ └── styles/ │ └── variables.scss # Vuetify custom theme │ ├── router/ │ └── index.ts # Vue Router + Navigation Guards │ ├── stores/ # Pinia Stores │ ├── auth.store.ts # Authentication & User session │ ├── application.store.ts # GAP application CRUD │ ├── inspection.store.ts # Inspection & checklist │ ├── certificate.store.ts # Certificate management │ └── notification.store.ts # Notifications │ ├── composables/ # Shared Composition Functions │ ├── useAuth.ts │ ├── usePermission.ts │ └── useNotification.ts │ ├── plugins/ │ ├── vuetify.ts # Vuetify configuration │ └── axios.ts # Axios instance + interceptors │ ├── layouts/ │ ├── DefaultLayout.vue # Sidebar + AppBar + Footer │ └── AuthLayout.vue # Login / SSO callback page │ ├── views/ │ ├── auth/ │ │ ├── LoginPage.vue │ │ └── SsoCallbackPage.vue │ │ │ ├── dashboard/ │ │ └── DashboardPage.vue │ │ │ ├── application/ │ │ ├── ApplicationListPage.vue │ │ ├── ApplicationFormPage.vue # Step Form (v-stepper) │ │ ├── ApplicationDetailPage.vue │ │ └── GroupApplicationPage.vue │ │ │ ├── inspection/ │ │ ├── InspectionSchedulePage.vue │ │ ├── InspectionChecklistPage.vue │ │ └── InspectionResultPage.vue │ │ │ ├── certificate/ │ │ ├── CertificateListPage.vue │ │ └── CertificateDetailPage.vue │ │ │ └── admin/ │ ├── UserManagementPage.vue │ └── SystemSettingPage.vue │ ├── components/ │ ├── common/ │ │ ├── AppConfirmDialog.vue # v-dialog confirm/cancel │ │ ├── AppStatusChip.vue # v-chip for statuses │ │ ├── AppFileUpload.vue # File/image uploader │ │ └── AppNotificationBell.vue # Notification dropdown │ │ │ ├── application/ │ │ ├── StepApplicantInfo.vue # Step 1: ข้อมูลผู้ขอ │ │ ├── StepPlotInfo.vue # Step 2: แปลงปลูก │ │ ├── StepProductionInfo.vue # Step 3: การผลิต │ │ ├── StepDocumentUpload.vue # Step 4: เอกสาร │ │ └── StepReviewSubmit.vue # Step 5: ตรวจสอบ & ยืนยัน │ │ │ ├── inspection/ │ │ ├── GapChecklist.vue # Checklist form │ │ └── InspectionPhotoUpload.vue # Photo upload grid │ │ │ └── certificate/ │ └── CertificatePreview.vue # PDF preview & download │ └── utils/ ├── constants.ts # Enums, status codes ├── validators.ts # Vuetify form rules └── pdf-generator.ts # Certificate PDF builder ``` ----- ## 2. Roles & Permissions ### 2.1 Role Definition |Role |รหัส |คำอธิบาย | |---------------|-------------|------------------------------| |**Farmer** |`FARMER` |เกษตรกรผู้ยื่นคำขอ GAP | |**Group Admin**|`GROUP_ADMIN`|หัวหน้ากลุ่มเกษตรกร จัดการคำขอรายกลุ่ม| |**staff** |`staff` |เจ้าหน้าที่ตรวจเอกสาร / อนุมัติ | |**Inspector** |`INSPECTOR` |ผู้ตรวจประเมินแปลง | |**Admin** |`ADMIN` |ผู้ดูแลระบบ | ### 2.2 Permission Matrix |Feature |Farmer|Group Admin|staff|Inspector|Admin| |-----------------|:----:|:---------:|:-----:|:-------:|:---:| |ยื่นคำขอรายเดี่ยว |✅ |✅ |❌ |❌ |❌ | |ยื่นคำขอรายกลุ่ม |❌ |✅ |❌ |❌ |❌ | |แก้ไข/ยกเลิกคำขอ |✅* |✅* |❌ |❌ |✅ | |ดู Dashboard ตนเอง|✅ |✅ |✅ |✅ |✅ | |ตรวจเอกสาร |❌ |❌ |✅ |❌ |✅ | |นัดตรวจแปลง |❌ |❌ |✅ |✅ |✅ | |บันทึกผลตรวจ GAP |❌ |❌ |❌ |✅ |✅ | |อนุมัติ/ปฏิเสธคำขอ |❌ |❌ |✅ |❌ |✅ | |ออกใบรับรอง |❌ |❌ |✅ |❌ |✅ | |จัดการผู้ใช้ |❌ |❌ |❌ |❌ |✅ | > ** แก้ไข/ยกเลิกได้เฉพาะคำขอของตนเองที่สถานะยังไม่ถึงขั้นอนุมัติ* ### 2.3 Route Guard & Permission Composable ```typescript // composables/usePermission.ts import { useAuthStore } from '@/stores/auth.store' export function usePermission() { const auth = useAuthStore() const hasRole = (roles: string[]) => roles.includes(auth.user?.role) const can = (action: string) => { const permissions: Record<string, string[]> = { 'application:create': ['FARMER', 'GROUP_ADMIN'], 'application:create-group': ['GROUP_ADMIN'], 'application:edit-own': ['FARMER', 'GROUP_ADMIN'], 'document:review': ['staff', 'ADMIN'], 'inspection:schedule': ['staff', 'INSPECTOR', 'ADMIN'], 'inspection:record': ['INSPECTOR', 'ADMIN'], 'application:approve': ['staff', 'ADMIN'], 'certificate:issue': ['staff', 'ADMIN'], 'user:manage': ['ADMIN'], } return (permissions[action] || []).includes(auth.user?.role) } return { hasRole, can } } ``` ----- ## 3. Authentication — SSO Flow ### 3.1 Flow Diagram ┌─────────────────────────────────────────────────┐ │ Landing / Login Page │ │ [เข้าสู่ระบบด้วย SSO] [สมัครสมาชิก] │ └────────────┬────────────────────┬───────────────┘ │ │ Login │ │ Register ▼ ▼ ┌──────────────────┐ ┌─────────────────────┐ │ SSO Login Flow │ │ SSO Register Flow │ │ (OAuth 2.0 Code) │ │ (ลงทะเบียนผู้ใช้ใหม่) │ └────────┬─────────┘ └──────────┬──────────┘ │ │ │ access_token │ access_token ▼ ▼ ┌──────────────────────────────────────────┐ │ /auth/callback │ │ Exchange code → token → fetch profile │ └────────────────────┬─────────────────────┘ │ ▼ ┌──────────────────────────────────────────┐ │ Portal Page (/portal) │ │ แสดงเมนูระบบตามสิทธิ์ (Role-based) │ └──────────────────────────────────────────┘ ### 3.2 Auth Store (Pinia) ```typescript // stores/auth.store.ts import { defineStore } from 'pinia' import { ref, computed } from 'vue' import axios from '@/plugins/axios' interface User { id: string fullName: string role: 'FARMER' | 'GROUP_ADMIN' | 'staff' | 'INSPECTOR' | 'ADMIN' email: string avatar?: string } export const useAuthStore = defineStore('auth', () => { const user = ref<User | null>(null) const token = ref<string | null>(null) const isAuthenticated = computed(() => !!token.value) const userRole = computed(() => user.value?.role) async function loginWithSso() { const ssoUrl = `${import.meta.env.VITE_SSO_URL}/authorize` + `?client_id=${import.meta.env.VITE_SSO_CLIENT_ID}` + `&redirect_uri=${encodeURIComponent(window.location.origin + '/auth/callback')}` + `&response_type=code` + `&scope=openid profile email` window.location.href = ssoUrl } async function handleCallback(code: string) { const { data } = await axios.post('/auth/token', { code }) token.value = data.accessToken axios.defaults.headers.common['Authorization'] = `Bearer ${data.accessToken}` const profile = await axios.get('/auth/me') user.value = profile.data } function logout() { user.value = null token.value = null delete axios.defaults.headers.common['Authorization'] window.location.href = `${import.meta.env.VITE_SSO_URL}/logout` } return { user, token, isAuthenticated, userRole, loginWithSso, handleCallback, logout } }, { persist: true }) ``` ### 3.3 SSO Callback Page ```vue <!-- views/auth/SsoCallbackPage.vue --> <template> <v-container class="d-flex justify-center align-center" style="min-height: 100vh"> <v-progress-circular indeterminate size="64" color="primary" /> <p class="ml-4 text-h6">กำลังเข้าสู่ระบบ...</p> </v-container> </template> <script setup lang="ts"> import { onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth.store' const route = useRoute() const router = useRouter() const auth = useAuthStore() onMounted(async () => { const code = route.query.code as string if (code) { await auth.handleCallback(code) router.replace({ name: 'Dashboard' }) } else { router.replace({ name: 'Login' }) } }) </script> ``` ----- ## 4. Router Configuration ```typescript // router/index.ts import { createRouter, createWebHistory } from 'vue-router' import { useAuthStore } from '@/stores/auth.store' const routes = [ // ── Auth ── { path: '/login', name: 'Login', component: () => import('@/views/auth/LoginPage.vue'), meta: { layout: 'auth', requiresAuth: false }, }, { path: '/auth/callback', name: 'SsoCallback', component: () => import('@/views/auth/SsoCallbackPage.vue'), meta: { layout: 'auth', requiresAuth: false }, }, // ── Dashboard ── { path: '/', name: 'Dashboard', component: () => import('@/views/dashboard/DashboardPage.vue'), meta: { requiresAuth: true }, }, // ── Application (คำขอ GAP) ── { path: '/applications', name: 'ApplicationList', component: () => import('@/views/application/ApplicationListPage.vue'), meta: { requiresAuth: true }, }, { path: '/applications/new', name: 'ApplicationCreate', component: () => import('@/views/application/ApplicationFormPage.vue'), meta: { requiresAuth: true, roles: ['FARMER', 'GROUP_ADMIN'] }, }, { path: '/applications/group/new', name: 'GroupApplicationCreate', component: () => import('@/views/application/GroupApplicationPage.vue'), meta: { requiresAuth: true, roles: ['GROUP_ADMIN'] }, }, { path: '/applications/:id', name: 'ApplicationDetail', component: () => import('@/views/application/ApplicationDetailPage.vue'), meta: { requiresAuth: true }, }, { path: '/applications/:id/edit', name: 'ApplicationEdit', component: () => import('@/views/application/ApplicationFormPage.vue'), meta: { requiresAuth: true, roles: ['FARMER', 'GROUP_ADMIN'] }, }, // ── Inspection (ตรวจประเมิน) ── { path: '/inspections', name: 'InspectionSchedule', component: () => import('@/views/inspection/InspectionSchedulePage.vue'), meta: { requiresAuth: true, roles: ['staff', 'INSPECTOR', 'ADMIN'] }, }, { path: '/inspections/:id/checklist', name: 'InspectionChecklist', component: () => import('@/views/inspection/InspectionChecklistPage.vue'), meta: { requiresAuth: true, roles: ['INSPECTOR', 'ADMIN'] }, }, { path: '/inspections/:id/result', name: 'InspectionResult', component: () => import('@/views/inspection/InspectionResultPage.vue'), meta: { requiresAuth: true, roles: ['staff', 'INSPECTOR', 'ADMIN'] }, }, // ── Certificate (ใบรับรอง) ── { path: '/certificates', name: 'CertificateList', component: () => import('@/views/certificate/CertificateListPage.vue'), meta: { requiresAuth: true }, }, { path: '/certificates/:id', name: 'CertificateDetail', component: () => import('@/views/certificate/CertificateDetailPage.vue'), meta: { requiresAuth: true }, }, // ── Admin ── { path: '/admin/users', name: 'UserManagement', component: () => import('@/views/admin/UserManagementPage.vue'), meta: { requiresAuth: true, roles: ['ADMIN'] }, }, { path: '/admin/settings', name: 'SystemSettings', component: () => import('@/views/admin/SystemSettingPage.vue'), meta: { requiresAuth: true, roles: ['ADMIN'] }, }, ] const router = createRouter({ history: createWebHistory(), routes, }) // ── Navigation Guard ── router.beforeEach((to, _from, next) => { const auth = useAuthStore() if (to.meta.requiresAuth && !auth.isAuthenticated) { return next({ name: 'Login' }) } if (to.meta.roles && !to.meta.roles.includes(auth.userRole)) { return next({ name: 'Dashboard' }) } next() }) export default router ``` ----- ## 5. Portal Page — หน้าเมนูระบบกลางgi ### 5.1 ภาพรวม Portal หลังจาก Login สำเร็จ ผู้ใช้จะถูก redirect มาที่หน้า Portal (/portal) ซึ่งทำหน้าที่เป็น Single Entry Point สำหรับทุกระบบภายใต้กรมวิชาการเกษตร โดยแสดงเฉพาะระบบที่ผู้ใช้มีสิทธิ์เข้าถึงตาม Role ที่ได้รับ Portal Layout: ┌──────────────────────จัด───────────────────────────────────────────┐ │ 🌿 ระบบรับรองมาตรฐานพืช (Header) [User] [Logout] │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ยินดีต้อนรับ, [ชื่อผู้ใช้] | บทบาท: [Role] │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ GAP │ │ DOA │ │ จดทะเบียน│ │ Health │ │ │ │ Cert. │ │ Factory │ │ ส่งออก │ │ Cert. 1 │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Health │ │ EL │ │ Admin │ │ │ │ Cert. 2 │ │ System │ │ Backend │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ ### 5.2 System Registry — นิยามระบบทั้งหมด // utils/portal-systems.ts export interface PortalSystem { id: string title: string titleEn: string description: string icon: string color: string routeName: string // ชื่อ route หรือ external URL external?: boolean // true = เปิด tab ใหม่ (microservice อื่น) externalUrl?: string requiredRoles: string[] // [] = ทุก role เข้าได้ badge?: string // ข้อความ badge เช่น "ใหม่", "Beta" } export const PORTAL_SYSTEMS: PortalSystem[] = [ { id: 'gap', title: 'ระบบการรับรองมาตรฐาน GAP', titleEn: 'GAP Certification System', description: 'ยื่นคำขอรับรอง ตรวจประเมินแปลง และออกใบรับรองมาตรฐาน GAP พืช', icon: 'mdi-leaf-circle', color: 'success', routeName: 'Dashboard', requiredRoles: [], // ทุก role }, { id: 'doa-factory', title: 'ระบบการขึ้นทะเบียนโรงงานผลิตสินค้าพืช DOA', titleEn: 'DOA Factory & Certification Body Registration', description: 'ขึ้นทะเบียนโรงงานผลิตสินค้าพืช DOA และหน่วยรับรองโรงงาน (Certification Body: CB)', icon: 'mdi-factory', color: 'primary', routeName: 'DoaFactoryDashboard', requiredRoles: ['FARMER', 'GROUP_ADMIN', 'staff', 'ADMIN'], }, { id: 'export-register', title: 'ระบบจดทะเบียนผู้ส่งออก', titleEn: 'Exporter Registration System', description: 'จดทะเบียนผู้ส่งออกสินค้าเกษตร และต่ออายุใบทะเบียน', icon: 'mdi-truck-delivery', color: 'orange', routeName: 'ExporterDashboard', requiredRoles: ['FARMER', 'GROUP_ADMIN', 'staff', 'ADMIN'], }, { id: 'health-cert-controlled', title: 'ระบบ Health Certificate', titleEn: 'Health Certificate — Controlled Plants', description: 'ออก Health Certificate ตามประกาศพืชควบคุมเฉพาะ', icon: 'mdi-file-certificate', color: 'teal', routeName: 'HealthCertControlledDashboard', requiredRoles: ['FARMER', 'GROUP_ADMIN', 'staff', 'ADMIN'], badge: 'พืชควบคุม', }, { id: 'health-cert-processed', title: 'ระบบ Health Certificate สินค้าเกษตรแปรรูปด้านพืช', titleEn: 'Health Certificate — Processed Agricultural Products', description: 'ออก Health Certificate สำหรับสินค้าเกษตรแปรรูปด้านพืช', icon: 'mdi-file-certificate-outline', color: 'cyan', routeName: 'HealthCertProcessedDashboard', requiredRoles: ['FARMER', 'GROUP_ADMIN', 'staff', 'ADMIN'], badge: 'สินค้าแปรรูป', }, { id: 'establishment-list', title: 'ระบบการควบคุมพิเศษ Establishment List (EL)', titleEn: 'Establishment List Management System', description: 'บริหารจัดการบัญชีรายชื่อโรงคัดบรรจุสินค้าเกษตรเพื่อการส่งออก', icon: 'mdi-format-list-checks', color: 'indigo', routeName: 'EstablishmentListDashboard', requiredRoles: ['staff', 'INSPECTOR', 'ADMIN'], }, { id: 'admin-backend', title: 'ระบบบริหารจัดการผู้ดูแลระบบ (Backend)', titleEn: 'Admin & Open API Management', description: 'บริหารจัดการผู้ใช้งาน สิทธิ์ระบบ และจัดการ Open API', icon: 'mdi-shield-crown', color: 'deep-purple', routeName: 'AdminPortal', requiredRoles: ['ADMIN'], badge: 'Admin', }, ] ### 5.3 Portal Permission Composable // composables/usePortalPermission.ts import { computed } from 'vue' import { useAuthStore } from '@/stores/auth.store' import { PORTAL_SYSTEMS, type PortalSystem } from '@/utils/portal-systems' export function usePortalPermission() { const auth = useAuthStore() const accessibleSystems = computed<PortalSystem[]>(() => PORTAL_SYSTEMS.filter(sys => { if (sys.requiredRoles.length === 0) return true return sys.requiredRoles.includes(auth.user?.role ?? '') }) ) const hasAccessTo = (systemId: string) => accessibleSystems.value.some(s => s.id === systemId) return { accessibleSystems, hasAccessTo } } ### 5.4 Portal Page Component <!-- views/portal/PortalPage.vue --> <template> <v-app :theme="'gapTheme'"> <!-- App Bar --> <v-app-bar flat color="primary" elevation="2"> <v-app-bar-title> <div class="d-flex align-center"> <v-icon size="28" color="white" class="mr-2">mdi-leaf</v-icon> <span class="text-white font-weight-bold">ระบบรับรองมาตรฐานพืช</span> <span class="text-white text-caption ml-2 opacity-70">กรมวิชาการเกษตร</span> </div> </v-app-bar-title> <template v-slot:append> <AppNotificationBell /> <v-menu> <template v-slot:activator="{ props }"> <v-btn v-bind="props" variant="text" class="text-white ml-1"> <v-avatar color="white" size="32" class="mr-2"> <span class="text-primary font-weight-bold text-body-2"> {{ auth.user?.fullName?.charAt(0) }} </span> </v-avatar> {{ auth.user?.fullName }} <v-icon end>mdi-chevron-down</v-icon> </v-btn> </template> <v-list min-width="220"> <v-list-item prepend-icon="mdi-account-circle" :title="auth.user?.fullName" :subtitle="roleLabel" /> <v-divider /> <v-list-item prepend-icon="mdi-account-edit" title="แก้ไขโปรไฟล์" @click="router.push({ name: 'UserProfile' })" /> <v-list-item prepend-icon="mdi-logout" title="ออกจากระบบ" @click="auth.logout()" base-color="error" /> </v-list> </v-menu> </template> </v-app-bar> <v-main class="portal-bg"> <v-container class="py-8" max-width="1200"> <!-- Welcome Banner --> <v-card color="primary" variant="tonal" class="mb-8" rounded="xl"> <v-card-text class="d-flex align-center pa-6"> <div> <h2 class="text-h5 font-weight-bold"> ยินดีต้อนรับ, {{ auth.user?.fullName }} 👋 </h2> <p class="text-body-2 mt-1 text-medium-emphasis"> บทบาท: <v-chip size="small" color="primary" class="ml-1">{{ roleLabel }}</v-chip> | สิทธิ์เข้าถึง {{ accessibleSystems.length }} ระบบ </p> </div> <v-spacer /> <v-icon size="80" color="primary" class="opacity-20">mdi-leaf-circle</v-icon> </v-card-text> </v-card> <!-- Systems Grid --> <h2 class="text-h6 font-weight-bold mb-4"> <v-icon start color="primary">mdi-apps</v-icon> ระบบที่คุณสามารถเข้าใช้งาน </h2> <v-row> <v-col v-for="system in accessibleSystems" :key="system.id" cols="12" sm="6" md="4" lg="3" > <v-card rounded="xl" elevation="2" class="system-card h-100" hover @click="navigateTo(system)" > <v-card-text class="pa-6"> <!-- Badge --> <div class="d-flex justify-space-between align-start mb-4"> <v-avatar :color="system.color" size="56" rounded="lg"> <v-icon size="30" color="white">{{ system.icon }}</v-icon> </v-avatar> <v-chip v-if="system.badge" :color="system.color" size="x-small" label > {{ system.badge }} </v-chip> </div> <!-- Title --> <h3 class="text-subtitle-1 font-weight-bold mb-2 system-title"> {{ system.title }} </h3> <p class="text-caption text-medium-emphasis"> {{ system.description }} </p> </v-card-text> <v-card-actions class="pa-4 pt-0"> <v-btn :color="system.color" variant="tonal" size="small" rounded="lg" block > <v-icon start size="16">mdi-arrow-right-circle</v-icon> เข้าใช้งาน </v-btn> </v-card-actions> </v-card> </v-col> </v-row> <!-- No access message --> <v-card v-if="accessibleSystems.length === 0" variant="outlined" rounded="xl" class="mt-4" > <v-card-text class="text-center pa-12"> <v-icon size="64" color="grey-lighten-1">mdi-lock-outline</v-icon> <p class="text-h6 mt-4 text-medium-emphasis">ยังไม่มีสิทธิ์เข้าใช้งานระบบ</p> <p class="text-body-2 text-medium-emphasis">กรุณาติดต่อผู้ดูแลระบบเพื่อขอสิทธิ์</p> </v-card-text> </v-card> </v-container> </v-main> </v-app> </template> <script setup lang="ts"> import { computed } from 'vue' import { useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth.store' import { usePortalPermission } from '@/composables/usePortalPermission' import { type PortalSystem } from '@/utils/portal-systems' import AppNotificationBell from '@/components/common/AppNotificationBell.vue' const auth = useAuthStore() const router = useRouter() const { accessibleSystems } = usePortalPermission() const roleLabels: Record<string, string> = { FARMER: 'เกษตรกร', GROUP_ADMIN: 'หัวหน้ากลุ่มเกษตรกร', staff: 'เจ้าหน้าที่', INSPECTOR: 'ผู้ตรวจประเมิน', ADMIN: 'ผู้ดูแลระบบ', } const roleLabel = computed(() => roleLabels[auth.user?.role ?? ''] ?? auth.user?.role) function navigateTo(system: PortalSystem) { if (system.external && system.externalUrl) { window.open(system.externalUrl, '_blank') } else { router.push({ name: system.routeName }) } } </script> <style scoped> .portal-bg { background: linear-gradient(160deg, #F1F8E9 0%, #E8F5E9 40%, #E0F7FA 100%); min-height: 100vh; } .system-card { cursor: pointer; transition: transform 0.2s ease, box-shadow 0.2s ease; } .system-card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.12) !important; } .system-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.4; min-height: 2.8em; } </style> ``` ----- ## 6. Application Step Form (v-stepper) ### 6.1 หน้า Step Form หลัก ```vue <!-- views/application/ApplicationFormPage.vue --> <template> <v-container> <v-card> <v-card-title class="text-h5 pa-6"> <v-icon start>mdi-file-document-edit</v-icon> ยื่นคำขอรับรอง GAP พืช </v-card-title> <v-stepper v-model="currentStep" :items="stepItems" alt-labels> <!-- Step 1: ข้อมูลผู้ขอ --> <template v-slot:item.1> <StepApplicantInfo v-model="form.applicant" ref="step1Ref" /> </template> <!-- Step 2: แปลงปลูก --> <template v-slot:item.2> <StepPlotInfo v-model="form.plots" ref="step2Ref" /> </template> <!-- Step 3: การผลิต --> <template v-slot:item.3> <StepProductionInfo v-model="form.production" ref="step3Ref" /> </template> <!-- Step 4: เอกสาร --> <template v-slot:item.4> <StepDocumentUpload v-model="form.documents" ref="step4Ref" /> </template> <!-- Step 5: ตรวจสอบ & ยืนยัน --> <template v-slot:item.5> <StepReviewSubmit :form-data="form" /> </template> <!-- Actions --> <template v-slot:actions> <v-btn v-if="currentStep > 1" variant="text" @click="currentStep--" > <v-icon start>mdi-arrow-left</v-icon> ย้อนกลับ </v-btn> <v-spacer /> <v-btn color="grey" variant="outlined" class="mr-2" @click="saveDraft"> <v-icon start>mdi-content-save</v-icon> บันทึกร่าง </v-btn> <v-btn v-if="currentStep < 5" color="primary" @click="goNext" > ถัดไป <v-icon end>mdi-arrow-right</v-icon> </v-btn> <v-btn v-else color="success" @click="submitApplication" :loading="isSubmitting" > <v-icon start>mdi-send</v-icon> ยื่นคำขอ </v-btn> </template> </v-stepper> </v-card> </v-container> </template> <script setup lang="ts"> import { ref, reactive } from 'vue' import { useRouter } from 'vue-router' import { useApplicationStore } from '@/stores/application.store' import StepApplicantInfo from '@/components/application/StepApplicantInfo.vue' import StepPlotInfo from '@/components/application/StepPlotInfo.vue' import StepProductionInfo from '@/components/application/StepProductionInfo.vue' import StepDocumentUpload from '@/components/application/StepDocumentUpload.vue' import StepReviewSubmit from '@/components/application/StepReviewSubmit.vue' const router = useRouter() const appStore = useApplicationStore() const currentStep = ref(1) const isSubmitting = ref(false) const step1Ref = ref() const step2Ref = ref() const step3Ref = ref() const step4Ref = ref() const stepItems = [ { title: 'ข้อมูลผู้ขอ', value: 1 }, { title: 'แปลงปลูก', value: 2 }, { title: 'การผลิต', value: 3 }, { title: 'เอกสาร', value: 4 }, { title: 'ตรวจสอบ', value: 5 }, ] const form = reactive({ applicant: { fullName: '', idCard: '', phone: '', address: '' }, plots: [], production: { cropType: '', area: '', method: '', startDate: '' }, documents: [], }) async function goNext() { const refs = [step1Ref, step2Ref, step3Ref, step4Ref] const stepRef = refs[currentStep.value - 1] const { valid } = await stepRef.value.validate() if (valid) currentStep.value++ } async function saveDraft() { await appStore.saveDraft(form) } async function submitApplication() { isSubmitting.value = true try { await appStore.submit(form) router.push({ name: 'ApplicationList' }) } finally { isSubmitting.value = false } } </script> ``` ### 6.2 Step 1 — ข้อมูลผู้ขอ (ตัวอย่าง Component) ```vue <!-- components/application/StepApplicantInfo.vue --> <template> <v-form ref="formRef"> <v-card flat> <v-card-text> <v-row> <v-col cols="12" md="6"> <v-text-field v-model="model.fullName" label="ชื่อ-นามสกุล" :rules="[rules.required]" prepend-inner-icon="mdi-account" /> </v-col> <v-col cols="12" md="6"> <v-text-field v-model="model.idCard" label="เลขบัตรประชาชน" :rules="[rules.required, rules.idCard]" prepend-inner-icon="mdi-card-account-details" maxlength="13" /> </v-col> <v-col cols="12" md="6"> <v-text-field v-model="model.phone" label="เบอร์โทรศัพท์" :rules="[rules.required, rules.phone]" prepend-inner-icon="mdi-phone" /> </v-col> <v-col cols="12"> <v-textarea v-model="model.address" label="ที่อยู่" :rules="[rules.required]" rows="3" prepend-inner-icon="mdi-map-marker" /> </v-col> </v-row> </v-card-text> </v-card> </v-form> </template> <script setup lang="ts"> import { ref } from 'vue' const model = defineModel({ required: true }) const formRef = ref() const rules = { required: (v: string) => !!v || 'กรุณากรอกข้อมูล', idCard: (v: string) => /^\d{13}$/.test(v) || 'เลขบัตรประชาชนไม่ถูกต้อง', phone: (v: string) => /^0\d{8,9}$/.test(v) || 'เบอร์โทรไม่ถูกต้อง', } function validate() { return formRef.value.validate() } defineExpose({ validate }) </script> ``` ### 6.3 Step 2 — แปลงปลูก (Dynamic Plot List) ```vue <!-- components/application/StepPlotInfo.vue --> <template> <v-form ref="formRef"> <v-card flat> <v-card-text> <div v-for="(plot, index) in model" :key="index" class="mb-4"> <v-card variant="outlined"> <v-card-title class="d-flex align-center"> แปลงที่ {{ index + 1 }} <v-spacer /> <v-btn icon="mdi-delete" color="error" size="small" variant="text" @click="removePlot(index)" v-if="model.length > 1" /> </v-card-title> <v-card-text> <v-row> <v-col cols="12" md="4"> <v-text-field v-model="plot.plotName" label="ชื่อแปลง" :rules="[rules.required]" /> </v-col> <v-col cols="12" md="4"> <v-text-field v-model.number="plot.area" label="พื้นที่ (ไร่)" type="number" :rules="[rules.required, rules.positive]" /> </v-col> <v-col cols="12" md="4"> <v-select v-model="plot.province" label="จังหวัด" :items="provinces" :rules="[rules.required]" /> </v-col> <v-col cols="12" md="6"> <v-text-field v-model.number="plot.latitude" label="ละติจูด" type="number" step="0.000001" /> </v-col> <v-col cols="12" md="6"> <v-text-field v-model.number="plot.longitude" label="ลองจิจูด" type="number" step="0.000001" /> </v-col> </v-row> </v-card-text> </v-card> </div> <v-btn color="primary" variant="outlined" block @click="addPlot"> <v-icon start>mdi-plus</v-icon> เพิ่มแปลง </v-btn> </v-card-text> </v-card> </v-form> </template> <script setup lang="ts"> import { ref } from 'vue' const model = defineModel<any[]>({ required: true, default: () => [createEmptyPlot()] }) const formRef = ref() const provinces = ['กรุงเทพมหานคร', 'เชียงใหม่', 'ขอนแก่น', 'นครราชสีมา', '...'] const rules = { required: (v: any) => !!v || 'กรุณากรอกข้อมูล', positive: (v: number) => v > 0 || 'กรุณากรอกค่ามากกว่า 0', } function createEmptyPlot() { return { plotName: '', area: null, province: '', latitude: null, longitude: null } } function addPlot() { model.value.push(createEmptyPlot()) } function removePlot(i: number) { model.value.splice(i, 1) } function validate() { return formRef.value.validate() } defineExpose({ validate }) </script> ``` ----- ## 7. Dashboard ### 7.1 Dashboard Layout ```vue <!-- views/dashboard/DashboardPage.vue --> <template> <v-container> <h1 class="text-h4 mb-6">Dashboard</h1> <!-- ── Summary Cards ── --> <v-row> <v-col v-for="card in summaryCards" :key="card.title" cols="12" sm="6" md="3"> <v-card :color="card.color" variant="tonal"> <v-card-text class="d-flex align-center"> <v-icon :icon="card.icon" size="48" class="mr-4" /> <div> <div class="text-h4 font-weight-bold">{{ card.value }}</div> <div class="text-body-2">{{ card.title }}</div> </div> </v-card-text> </v-card> </v-col> </v-row> <!-- ── Notifications ── --> <v-card class="mt-6"> <v-card-title> <v-icon start>mdi-bell</v-icon> การแจ้งเตือนล่าสุด </v-card-title> <v-list> <v-list-item v-for="noti in notifications" :key="noti.id" :prepend-icon="noti.icon" :subtitle="noti.date" > <v-list-item-title>{{ noti.message }}</v-list-item-title> <template v-slot:append> <AppStatusChip :status="noti.status" /> </template> </v-list-item> </v-list> </v-card> <!-- ── Recent Applications Table ── --> <v-card class="mt-6"> <v-card-title> <v-icon start>mdi-file-document</v-icon> คำขอล่าสุด </v-card-title> <v-data-table :headers="tableHeaders" :items="recentApplications" :items-per-page="5" density="comfortable" > <template v-slot:item.status="{ value }"> <AppStatusChip :status="value" /> </template> <template v-slot:item.actions="{ item }"> <v-btn icon="mdi-eye" size="small" variant="text" :to="{ name: 'ApplicationDetail', params: { id: item.id } }" /> </template> </v-data-table> </v-card> </v-container> </template> <script setup lang="ts"> import { ref, onMounted, computed } from 'vue' import { useAuthStore } from '@/stores/auth.store' import { useApplicationStore } from '@/stores/application.store' import { useNotificationStore } from '@/stores/notification.store' import AppStatusChip from '@/components/common/AppStatusChip.vue' const auth = useAuthStore() const appStore = useApplicationStore() const notiStore = useNotificationStore() onMounted(async () => { await appStore.fetchDashboardSummary() await notiStore.fetchRecent() }) const summaryCards = computed(() => [ { title: 'คำขอทั้งหมด', value: appStore.summary.total, icon: 'mdi-file-multiple', color: 'primary' }, { title: 'รอดำเนินการ', value: appStore.summary.pending, icon: 'mdi-clock-outline', color: 'warning' }, { title: 'ผ่านการรับรอง', value: appStore.summary.approved, icon: 'mdi-check-circle-outline', color: 'success' }, { title: 'ใบรับรองที่ใช้งานอยู่', value: appStore.summary.activeCerts, icon: 'mdi-certificate', color: 'info' }, ]) const notifications = computed(() => notiStore.items) const recentApplications = computed(() => appStore.recentList) const tableHeaders = [ { title: 'เลขที่คำขอ', key: 'applicationNo' }, { title: 'ประเภทพืช', key: 'cropType' }, { title: 'วันที่ยื่น', key: 'submittedAt' }, { title: 'สถานะ', key: 'status' }, { title: '', key: 'actions', sortable: false }, ] </script> ``` ### 7.2 Status Chip Component ```vue <!-- components/common/AppStatusChip.vue --> <template> <v-chip :color="statusColor" size="small" label> <v-icon start size="14">{{ statusIcon }}</v-icon> {{ statusLabel }} </v-chip> </template> <script setup lang="ts"> import { computed } from 'vue' const props = defineProps<{ status: string }>() const statusMap: Record<string, { color: string; icon: string; label: string }> = { DRAFT: { color: 'grey', icon: 'mdi-pencil', label: 'ร่าง' }, SUBMITTED: { color: 'blue', icon: 'mdi-send', label: 'ยื่นแล้ว' }, DOC_REVIEW: { color: 'orange', icon: 'mdi-file-search', label: 'ตรวจเอกสาร' }, INSPECTION_SCHEDULED: { color: 'purple', icon: 'mdi-calendar-clock', label: 'นัดตรวจ' }, INSPECTING: { color: 'indigo', icon: 'mdi-clipboard-check', label: 'กำลังตรวจ' }, APPROVED: { color: 'green', icon: 'mdi-check-circle', label: 'อนุมัติ' }, REJECTED: { color: 'red', icon: 'mdi-close-circle', label: 'ไม่ผ่าน' }, CANCELLED: { color: 'grey', icon: 'mdi-cancel', label: 'ยกเลิก' }, CERT_ISSUED: { color: 'teal', icon: 'mdi-certificate', label: 'ออกใบรับรอง' }, CERT_EXPIRED: { color: 'brown', icon: 'mdi-clock-alert', label: 'หมดอายุ' }, } const current = computed(() => statusMap[props.status] || statusMap.DRAFT) const statusColor = computed(() => current.value.color) const statusIcon = computed(() => current.value.icon) const statusLabel = computed(() => current.value.label) </script> ``` ----- ## 8. Application State Flow ``` ┌────────┐ ┌─────────│ DRAFT │──────── บันทึกร่าง │ └───┬────┘ │ ยกเลิก │ ยื่นคำขอ ▼ ▼ ┌───────────┐ ┌───────────┐ │ CANCELLED │ │ SUBMITTED │ └───────────┘ └─────┬─────┘ │ เจ้าหน้าที่รับเรื่อง ▼ ┌────────────┐ │ DOC_REVIEW │ ─── ตรวจเอกสาร └─────┬──────┘ เอกสารไม่ผ่าน │ │ เอกสารผ่าน (ส่งกลับแก้ไข)│ ▼ ▲ │ ┌─────────────────────┐ │ │ │ INSPECTION_SCHEDULED │ ─── นัดตรวจแปลง │ │ └──────────┬──────────┘ │ │ ▼ │ │ ┌────────────┐ │ │ │ INSPECTING │ ─── บันทึกผลตรวจ │ │ └─────┬──────┘ │ │ ไม่ผ่าน │ ผ่าน │ │ ┌──────┴──────┐ │ ▼ ▼ ▼ │ ┌──────────┐ ┌──────────┐ │ │ REJECTED │ │ APPROVED │ │ └──────────┘ └────┬─────┘ │ │ ออกใบรับรอง │ ▼ │ ┌─────────────┐ │ │ CERT_ISSUED │ │ └──────┬──────┘ │ │ หมดอายุ │ ▼ │ ┌──────────────┐ └───────────────────────│ CERT_EXPIRED │ ── ยื่นต่ออายุ (loop) └──────────────┘ ``` ----- ## 9. Inspection Module ### 9.1 GAP Checklist Component ```vue <!-- components/inspection/GapChecklist.vue --> <template> <v-card> <v-card-title> <v-icon start>mdi-clipboard-check-outline</v-icon> รายการตรวจประเมิน GAP </v-card-title> <v-card-text> <v-expansion-panels variant="accordion"> <v-expansion-panel v-for="(category, ci) in checklist" :key="ci" :title="category.title" > <v-expansion-panel-text> <v-table density="compact"> <thead> <tr> <th style="width: 50%">ข้อกำหนด</th> <th style="width: 20%">ผลตรวจ</th> <th style="width: 30%">หมายเหตุ</th> </tr> </thead> <tbody> <tr v-for="(item, ii) in category.items" :key="ii"> <td>{{ item.label }}</td> <td> <v-btn-toggle v-model="item.result" mandatory density="compact" color="primary" > <v-btn value="PASS" size="small" color="success">ผ่าน</v-btn> <v-btn value="FAIL" size="small" color="error">ไม่ผ่าน</v-btn> <v-btn value="NA" size="small">N/A</v-btn> </v-btn-toggle> </td> <td> <v-text-field v-model="item.remark" density="compact" variant="underlined" hide-details placeholder="หมายเหตุ" /> </td> </tr> </tbody> </v-table> </v-expansion-panel-text> </v-expansion-panel> </v-expansion-panels> </v-card-text> </v-card> </template> <script setup lang="ts"> interface CheckItem { label: string result: 'PASS' | 'FAIL' | 'NA' | null remark: string } interface CheckCategory { title: string items: CheckItem[] } const checklist = defineModel<CheckCategory[]>({ required: true }) </script> ``` ### 9.2 GAP Checklist Data (ตัวอย่าง) ```typescript // utils/gap-checklist-template.ts export const GAP_CHECKLIST_TEMPLATE = [ { title: '1. แหล่งน้ำ', items: [ { label: '1.1 แหล่งน้ำไม่มีการปนเปื้อนสารเคมี', result: null, remark: '' }, { label: '1.2 มีระบบการจัดการน้ำอย่างเหมาะสม', result: null, remark: '' }, { label: '1.3 มีการตรวจวิเคราะห์คุณภาพน้ำ', result: null, remark: '' }, ], }, { title: '2. พื้นที่ปลูก', items: [ { label: '2.1 พื้นที่ไม่มีสารปนเปื้อนในดิน', result: null, remark: '' }, { label: '2.2 ไม่อยู่ใกล้แหล่งมลพิษ', result: null, remark: '' }, { label: '2.3 มีการจัดการดินอย่างเหมาะสม', result: null, remark: '' }, ], }, { title: '3. วัตถุอันตรายทางการเกษตร', items: [ { label: '3.1 ใช้สารเคมีตามคำแนะนำ', result: null, remark: '' }, { label: '3.2 มีการเก็บรักษาสารเคมีอย่างปลอดภัย', result: null, remark: '' }, { label: '3.3 ผู้ใช้สารเคมีมีอุปกรณ์ป้องกัน', result: null, remark: '' }, ], }, { title: '4. การจัดการคุณภาพในกระบวนการผลิตก่อนการเก็บเกี่ยว', items: [ { label: '4.1 ใช้พันธุ์พืชที่เหมาะสม', result: null, remark: '' }, { label: '4.2 มีการจดบันทึกการผลิต', result: null, remark: '' }, ], }, { title: '5. การเก็บเกี่ยวและการปฏิบัติหลังเก็บเกี่ยว', items: [ { label: '5.1 เก็บเกี่ยวในระยะเวลาเหมาะสม', result: null, remark: '' }, { label: '5.2 ภาชนะสะอาดและเหมาะสม', result: null, remark: '' }, { label: '5.3 สถานที่เก็บรักษาสะอาดปลอดภัย', result: null, remark: '' }, ], }, { title: '6. การพักผ่อนของสารเคมี', items: [ { label: '6.1 ปฏิบัติตามระยะเวลาหยุดใช้สารเคมีก่อนเก็บเกี่ยว', result: null, remark: '' }, ], }, { title: '7. การบันทึกข้อมูลและการตามสอบ', items: [ { label: '7.1 มีบันทึกการใช้ปัจจัยการผลิต', result: null, remark: '' }, { label: '7.2 สามารถตามสอบได้ตลอดห่วงโซ่', result: null, remark: '' }, ], }, { title: '8. สุขลักษณะส่วนบุคคล', items: [ { label: '8.1 ผู้ปฏิบัติงานมีสุขลักษณะที่ดี', result: null, remark: '' }, { label: '8.2 มีสิ่งอำนวยความสะดวกด้านสุขอนามัย', result: null, remark: '' }, ], }, ] ``` ### 9.3 Photo Upload for Inspection ```vue <!-- components/inspection/InspectionPhotoUpload.vue --> <template> <v-card> <v-card-title> <v-icon start>mdi-camera</v-icon> อัปโหลดรูปภาพการตรวจ </v-card-title> <v-card-text> <v-file-input v-model="newFiles" label="เลือกรูปภาพ" accept="image/*" multiple prepend-icon="mdi-camera-plus" show-size chips @update:model-value="onFilesSelected" /> <v-row class="mt-2"> <v-col v-for="(photo, index) in photos" :key="index" cols="6" sm="4" md="3" > <v-card variant="outlined"> <v-img :src="photo.preview" height="150" cover /> <v-card-text class="pa-2"> <v-text-field v-model="photo.caption" density="compact" variant="underlined" placeholder="คำอธิบายรูป" hide-details /> </v-card-text> <v-card-actions class="pa-1"> <v-spacer /> <v-btn icon="mdi-delete" color="error" size="small" @click="removePhoto(index)" /> </v-card-actions> </v-card> </v-col> </v-row> </v-card-text> </v-card> </template> <script setup lang="ts"> import { ref } from 'vue' interface Photo { file: File; preview: string; caption: string } const photos = defineModel<Photo[]>({ default: () => [] }) const newFiles = ref<File[]>([]) function onFilesSelected(files: File[]) { if (!files) return for (const file of files) { const preview = URL.createObjectURL(file) photos.value.push({ file, preview, caption: '' }) } newFiles.value = [] } function removePhoto(index: number) { URL.revokeObjectURL(photos.value[index].preview) photos.value.splice(index, 1) } </script> ``` ----- ## 10. Certificate Module ### 10.1 Certificate List (v-data-table) ```vue <!-- views/certificate/CertificateListPage.vue --> <template> <v-container> <v-card> <v-card-title class="d-flex align-center"> <v-icon start>mdi-certificate</v-icon> ใบรับรอง GAP <v-spacer /> <v-text-field v-model="search" prepend-inner-icon="mdi-magnify" label="ค้นหา" density="compact" variant="outlined" hide-details class="ml-4" style="max-width: 300px" /> </v-card-title> <v-data-table :headers="headers" :items="certificates" :search="search" :loading="loading" hover > <template v-slot:item.status="{ value }"> <AppStatusChip :status="value" /> </template> <template v-slot:item.expiryDate="{ value }"> <span :class="isExpiringSoon(value) ? 'text-warning font-weight-bold' : ''"> {{ formatDate(value) }} </span> </template> <template v-slot:item.actions="{ item }"> <v-btn icon="mdi-eye" size="small" variant="text" :to="{ name: 'CertificateDetail', params: { id: item.id } }" /> <v-btn icon="mdi-download" size="small" variant="text" color="primary" @click="downloadPdf(item.id)" /> </template> </v-data-table> </v-card> </v-container> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue' import { useCertificateStore } from '@/stores/certificate.store' import AppStatusChip from '@/components/common/AppStatusChip.vue' const certStore = useCertificateStore() const search = ref('') const loading = ref(false) const headers = [ { title: 'เลขที่ใบรับรอง', key: 'certNo' }, { title: 'เกษตรกร', key: 'farmerName' }, { title: 'ชนิดพืช', key: 'cropType' }, { title: 'วันที่ออก', key: 'issuedDate' }, { title: 'วันหมดอายุ', key: 'expiryDate' }, { title: 'สถานะ', key: 'status' }, { title: '', key: 'actions', sortable: false }, ] const certificates = ref([]) onMounted(async () => { loading.value = true certificates.value = await certStore.fetchAll() loading.value = false }) function formatDate(d: string) { return new Date(d).toLocaleDateString('th-TH', { year: 'numeric', month: 'short', day: 'numeric' }) } function isExpiringSoon(d: string) { const diff = new Date(d).getTime() - Date.now() return diff > 0 && diff < 30 * 24 * 60 * 60 * 1000 } async function downloadPdf(id: string) { await certStore.downloadPdf(id) } </script> ``` ### 10.2 Certificate PDF Generation ```typescript // utils/pdf-generator.ts import jsPDF from 'jspdf' interface CertData { certNo: string farmerName: string idCard: string cropType: string plotAddress: string area: string issuedDate: string expiryDate: string inspectorName: string approverName: string } export function generateCertificatePdf(data: CertData): jsPDF { const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' }) // Border doc.setDrawColor(34, 139, 34) doc.setLineWidth(2) doc.rect(10, 10, 277, 190) doc.setLineWidth(0.5) doc.rect(14, 14, 269, 182) // Header doc.setFontSize(24) doc.setTextColor(34, 139, 34) doc.text('ใบรับรองแหล่งผลิต GAP พืช', 148.5, 40, { align: 'center' }) doc.setFontSize(14) doc.text('Certificate of Good Agricultural Practices', 148.5, 50, { align: 'center' }) // Certificate Number doc.setFontSize(12) doc.setTextColor(0, 0, 0) doc.text(`เลขที่ใบรับรอง: ${data.certNo}`, 148.5, 65, { align: 'center' }) // Content doc.setFontSize(11) const startY = 80 const lineHeight = 10 const lines = [ `ขอรับรองว่า ${data.farmerName}`, `เลขบัตรประชาชน: ${data.idCard}`, `ได้ผ่านการตรวจประเมินแปลงผลิตพืช: ${data.cropType}`, `สถานที่ตั้ง: ${data.plotAddress}`, `พื้นที่: ${data.area} ไร่`, `ตามมาตรฐาน GAP (Good Agricultural Practices)`, ] lines.forEach((line, i) => { doc.text(line, 40, startY + i * lineHeight) }) // Dates doc.text(`วันที่ออกใบรับรอง: ${data.issuedDate}`, 40, 155) doc.text(`วันหมดอายุ: ${data.expiryDate}`, 40, 165) // Signatures doc.text('ผู้ตรวจประเมิน', 80, 185, { align: 'center' }) doc.text(data.inspectorName, 80, 192, { align: 'center' }) doc.line(40, 182, 120, 182) doc.text('ผู้อนุมัติ', 220, 185, { align: 'center' }) doc.text(data.approverName, 220, 192, { align: 'center' }) doc.line(180, 182, 260, 182) return doc } ``` ### 10.3 Certificate Store ```typescript // stores/certificate.store.ts import { defineStore } from 'pinia' import { ref } from 'vue' import axios from '@/plugins/axios' import { generateCertificatePdf } from '@/utils/pdf-generator' export const useCertificateStore = defineStore('certificate', () => { const certificates = ref([]) const current = ref(null) async function fetchAll() { const { data } = await axios.get('/certificates') certificates.value = data return data } async function fetchById(id: string) { const { data } = await axios.get(`/certificates/${id}`) current.value = data return data } async function downloadPdf(id: string) { const cert = await fetchById(id) const pdf = generateCertificatePdf(cert) pdf.save(`GAP-Certificate-${cert.certNo}.pdf`) } return { certificates, current, fetchAll, fetchById, downloadPdf } }) ``` ----- ## 11. Application Store (Pinia) ```typescript // stores/application.store.ts import { defineStore } from 'pinia' import { ref, reactive } from 'vue' import axios from '@/plugins/axios' interface DashboardSummary { total: number pending: number approved: number activeCerts: number } export const useApplicationStore = defineStore('application', () => { const summary = reactive<DashboardSummary>({ total: 0, pending: 0, approved: 0, activeCerts: 0 }) const recentList = ref([]) const currentApp = ref(null) async function fetchDashboardSummary() { const { data } = await axios.get('/applications/summary') Object.assign(summary, data) const recent = await axios.get('/applications?limit=10&sort=-submittedAt') recentList.value = recent.data } async function fetchById(id: string) { const { data } = await axios.get(`/applications/${id}`) currentApp.value = data return data } async function saveDraft(form: any) { if (form.id) { await axios.put(`/applications/${form.id}`, { ...form, status: 'DRAFT' }) } else { const { data } = await axios.post('/applications', { ...form, status: 'DRAFT' }) form.id = data.id } } async function submit(form: any) { if (form.id) { await axios.put(`/applications/${form.id}`, { ...form, status: 'SUBMITTED' }) } else { await axios.post('/applications', { ...form, status: 'SUBMITTED' }) } } async function cancel(id: string) { await axios.patch(`/applications/${id}/status`, { status: 'CANCELLED' }) } async function updateStatus(id: string, status: string, payload?: any) { await axios.patch(`/applications/${id}/status`, { status, ...payload }) } return { summary, recentList, currentApp, fetchDashboardSummary, fetchById, saveDraft, submit, cancel, updateStatus } }) ``` ----- ## 12. Sidebar Navigation ```vue <!-- layouts/DefaultLayout.vue (partial — navigation items) --> <template> <v-app> <v-navigation-drawer app permanent> <v-list-item prepend-icon="mdi-leaf" title="GAP Certification" subtitle="ระบบรับรองแหล่งผลิต" /> <v-divider /> <v-list density="compact" nav> <v-list-item v-for="item in filteredMenuItems" :key="item.title" :prepend-icon="item.icon" :title="item.title" :to="item.to" link /> </v-list> </v-navigation-drawer> <v-app-bar app flat> <v-spacer /> <AppNotificationBell /> <v-menu> <template v-slot:activator="{ props }"> <v-btn v-bind="props" icon> <v-avatar color="primary" size="36"> {{ auth.user?.fullName?.charAt(0) }} </v-avatar> </v-btn> </template> <v-list> <v-list-item prepend-icon="mdi-account" :title="auth.user?.fullName" /> <v-list-item prepend-icon="mdi-badge-account" :subtitle="auth.user?.role" /> <v-divider /> <v-list-item prepend-icon="mdi-logout" title="ออกจากระบบ" @click="auth.logout()" /> </v-list> </v-menu> </v-app-bar> <v-main> <router-view /> </v-main> </v-app> </template> <script setup lang="ts"> import { computed } from 'vue' import { useAuthStore } from '@/stores/auth.store' import { usePermission } from '@/composables/usePermission' import AppNotificationBell from '@/components/common/AppNotificationBell.vue' const auth = useAuthStore() const { hasRole } = usePermission() const menuItems = [ { title: 'Dashboard', icon: 'mdi-view-dashboard', to: '/', roles: ['ALL'] }, { title: 'คำขอ GAP', icon: 'mdi-file-document-edit', to: '/applications', roles: ['ALL'] }, { title: 'ยื่นคำขอใหม่', icon: 'mdi-plus-circle', to: '/applications/new', roles: ['FARMER', 'GROUP_ADMIN'] }, { title: 'ตรวจประเมิน', icon: 'mdi-clipboard-check', to: '/inspections', roles: ['staff', 'INSPECTOR', 'ADMIN'] }, { title: 'ใบรับรอง', icon: 'mdi-certificate', to: '/certificates', roles: ['ALL'] }, { title: 'จัดการผู้ใช้', icon: 'mdi-account-cog', to: '/admin/users', roles: ['ADMIN'] }, { title: 'ตั้งค่าระบบ', icon: 'mdi-cog', to: '/admin/settings', roles: ['ADMIN'] }, ] const filteredMenuItems = computed(() => menuItems.filter(i => i.roles.includes('ALL') || hasRole(i.roles)) ) </script> ``` ----- ## 13. Vuetify Theme Configuration ```typescript // plugins/vuetify.ts import { createVuetify } from 'vuetify' import * as components from 'vuetify/components' import * as directives from 'vuetify/directives' import '@mdi/font/css/materialdesignicons.css' import 'vuetify/styles' export default createVuetify({ components, directives, theme: { defaultTheme: 'gapTheme', themes: { gapTheme: { dark: false, colors: { primary: '#2E7D32', // เขียวเกษตร secondary: '#FF8F00', // เหลืองทอง accent: '#00ACC1', success: '#43A047', warning: '#FB8C00', error: '#E53935', info: '#1E88E5', background: '#F5F5F5', surface: '#FFFFFF', }, }, }, }, defaults: { VBtn: { rounded: 'lg' }, VCard: { rounded: 'lg', elevation: 2 }, VTextField: { variant: 'outlined', density: 'comfortable' }, VSelect: { variant: 'outlined', density: 'comfortable' }, }, }) ``` ----- ## 14. API Endpoints Summary |Method |Endpoint |Description |Roles | |-------|--------------------------|------------------------------------|-------------------------| |`POST` |`/auth/token` |Exchange SSO code for token |Public | |`GET` |`/auth/me` |Get current user profile |All | |`GET` |`/applications` |List applications (filtered by role)|All | |`GET` |`/applications/summary` |Dashboard summary counts |All | |`POST` |`/applications` |Create new application |Farmer, GroupAdmin | |`GET` |`/applications/:id` |Get application detail |All | |`PUT` |`/applications/:id` |Update application |Farmer, GroupAdmin | |`PATCH`|`/applications/:id/status`|Update status |staff, Admin | |`GET` |`/inspections` |List inspections |staff, Inspector, Admin| |`POST` |`/inspections` |Schedule inspection |staff, Admin | |`PUT` |`/inspections/:id` |Record inspection result |Inspector, Admin | |`POST` |`/inspections/:id/photos` |Upload inspection photos |Inspector | |`GET` |`/certificates` |List certificates |All | |`GET` |`/certificates/:id` |Get certificate detail |All | |`POST` |`/certificates` |Issue certificate |staff, Admin | |`GET` |`/certificates/:id/pdf` |Download certificate PDF |All | |`GET` |`/users` |List users |Admin | |`POST` |`/users` |Create user |Admin | |`PUT` |`/users/:id` |Update user |Admin | |`GET` |`/notifications` |Get notifications |All | ----- ## 15. Environment Variables ```env # .env VITE_API_BASE_URL=https://api.gap-cert.example.com VITE_SSO_URL=https://sso.example.com VITE_SSO_CLIENT_ID=gap-cert-web VITE_SSO_REDIRECT_URI=http://localhost:3000/auth/callback VITE_APP_TITLE=ระบบรับรองแหล่งผลิต GAP พืช ``` ----- ## 16. Deployment & DevOps Notes |Concern |Recommendation | |------------|------------------------------------| |Build |`vite build` → static SPA in `dist/`| |Hosting |Nginx / CloudFront + S3 | |SPA Fallback|`try_files $uri $uri/ /index.html` | |HTTPS |Required for SSO redirect | |Docker |Multi-stage build (Node → Nginx) | |CI/CD |GitHub Actions / GitLab CI | |Linting |ESLint + Prettier + vue-tsc | |Testing |Vitest (unit) + Cypress (E2E) |
Security Status
Unvetted
Not yet security scanned
Related AI Tools
More Make Money tools you might like
Social Autoposter
Free"Automate social media posting across Reddit, X/Twitter, LinkedIn, and Moltbook. Find threads, post comments, create original posts, track engagement stats. Use when: 'post to social', 'social autoposter', 'find threads to comment on', 'create a post
PICT Test Designer
FreeDesign comprehensive test cases using PICT (Pairwise Independent Combinatorial Testing) for any piece of requirements or code. Analyzes inputs, generates PICT models with parameters, values, and constraints for valid scenarios using pairwise testing.
Product Manager Skills
FreePM skill for Claude Code, Codex, Cursor, and Windsurf. Diagnoses SaaS metrics, critiques PRDs, plans roadmaps, runs discovery, coaches PM career transitions, pressure-tests AI product decisions, and designs PLG growth strategies. Seven knowledge doma
paper-fetch
FreeUse when the user wants to download a paper PDF from a DOI, title, or URL via legal open-access sources. Tries Unpaywall, arXiv, bioRxiv/medRxiv, PubMed Central, and Semantic Scholar in order. Never uses Sci-Hub or paywall bypass.
Beautiful Prose (Claude Skill)
FreeA hard-edged writing style contract for timeless, forceful English prose without modern AI tics. Use when users ask for prose or rewrites that must be clean, exact, concrete, and free of AI cadence, filler, or therapeutic tone.
SkillCheck (Free)
FreeValidate Claude Code skills against Anthropic guidelines. Use when user says "check skill", "skillcheck", "validate SKILL.md", or asks to find issues in skill definitions. Covers structural and semantic validation. Do NOT use for anti-slop detection,