Compare commits
18 Commits
1bb7b0d737
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 484f89e2ee | |||
| 03fe4b1fd4 | |||
| 73a32380e7 | |||
| d5cb197f96 | |||
| bd77e1a4e7 | |||
| 843b21b276 | |||
| 63458f397d | |||
| d57332ecf9 | |||
| cc038a7372 | |||
| c539fe47af | |||
| a06d0badfc | |||
| 974ba0e3c3 | |||
| 8e07f38df5 | |||
| e016f18ce6 | |||
| 0808fec88b | |||
| 7e6a087049 | |||
| 970ed3a69c | |||
| 20c410bcac |
@@ -0,0 +1,5 @@
|
||||
# CodeGraph data files — local to each machine, not for committing.
|
||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||
*
|
||||
!.gitignore
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
# CoreUI Free Vue Admin Template - AI Assistant Rules
|
||||
|
||||
You are working with the CoreUI Free Vue Admin Template, a professional admin dashboard built with Vue 3, CoreUI Vue components, and modern build tools. This project uses Vite for development and building, Vue Router for navigation, Pinia for state management, and Sass for styling.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
**Component Library**: ALWAYS use CoreUI Vue components from https://coreui.io/vue/docs/. NEVER use Tailwind CSS, Vuetify, Element Plus, or other component libraries. This project is built on Bootstrap 5 and CoreUI Vue components exclusively.
|
||||
|
||||
**Technology Stack**: This project uses:
|
||||
- Vue 3.5.x with Composition API and script setup
|
||||
- Single File Components (SFC) with <template>, <script>, and <style> sections
|
||||
- CoreUI Vue 5.x and @coreui/coreui 5.x
|
||||
- Vue Router 5.x for client-side routing
|
||||
- Pinia 3.x for state management
|
||||
- Vite 8.x for development server and building
|
||||
- Sass/SCSS for styling with Bootstrap 5 variables
|
||||
- Chart.js 4.x with @coreui/vue-chartjs for data visualization
|
||||
|
||||
## Code Conventions
|
||||
|
||||
**Vue/JavaScript Standards**:
|
||||
- Use Composition API with `<script setup>` syntax
|
||||
- Use ref() and reactive() for reactive state
|
||||
- Follow Vue 3 Composition API patterns
|
||||
- Use Prettier formatting: no semicolons, single quotes, 2-space indentation
|
||||
- Enforce ESLint rules with Vue and Prettier plugins
|
||||
- Prefer const and arrow functions
|
||||
- Use destructuring where appropriate
|
||||
|
||||
**File Organization**:
|
||||
- `src/` - All source code
|
||||
- `components/` - Reusable UI components (AppHeader, AppSidebar, etc.)
|
||||
- `views/` - Page components organized by feature (dashboard, forms, charts, etc.)
|
||||
- `layouts/` - Layout wrapper components (DefaultLayout)
|
||||
- `assets/` - Static assets (images, brand logos)
|
||||
- `scss/` - Global styles and theme customization
|
||||
- `router/` - Router configuration
|
||||
- `stores/` - Pinia stores
|
||||
- `_nav.js` - Navigation/sidebar menu configuration
|
||||
- `App.vue` - Main application component
|
||||
- `main.js` - Application entry point
|
||||
|
||||
**Vue/SFC Practices**:
|
||||
- Use `<script setup>` for components (preferred modern syntax)
|
||||
- Use `defineProps()` and `defineEmits()` for component API
|
||||
- Use computed() for derived state
|
||||
- Use watch() or watchEffect() for side effects
|
||||
- Keep components focused and single-responsibility
|
||||
- Extract reusable logic into composables (useX pattern)
|
||||
- Use Suspense for async components when needed
|
||||
|
||||
**CSS/Sass Practices**:
|
||||
- Import global styles in main.js: `import './scss/style.scss'`
|
||||
- Use Bootstrap utilities first before custom CSS
|
||||
- Leverage CoreUI CSS custom properties for theming
|
||||
- Support dark mode through CoreUI's color mode system
|
||||
- File: `src/scss/style.scss` - main stylesheet importing CoreUI and Bootstrap
|
||||
- File: `src/scss/_custom.scss` - custom style overrides
|
||||
- Use scoped styles in components: `<style scoped>`
|
||||
- Use SCSS variables from Bootstrap and CoreUI when possible
|
||||
|
||||
**Routing Conventions**:
|
||||
- Use createWebHashHistory for client-side routing (GitHub Pages compatible)
|
||||
- Define routes in router/index.js as array of objects
|
||||
- Use dynamic imports for lazy loading route components
|
||||
- Use exact path matching where needed
|
||||
- Public routes (login, register, 404, 500) defined separately
|
||||
- Protected routes handled in DefaultLayout
|
||||
|
||||
**State Management**:
|
||||
- Use Pinia for global state (theme, sidebar visibility)
|
||||
- Create stores in stores/ directory
|
||||
- Use defineStore() with setup syntax
|
||||
- Access stores with const store = useStore()
|
||||
- Keep component-level state in ref() or reactive() when state is local
|
||||
|
||||
**Naming Conventions**:
|
||||
- PascalCase for component files and component names (AppHeader.vue, DefaultLayout.vue)
|
||||
- camelCase for variables, functions, and composables (useState, useEffect)
|
||||
- UPPER_SNAKE_CASE for constants (API_URL, MAX_ITEMS)
|
||||
- kebab-case for CSS classes (following Bootstrap/CoreUI conventions)
|
||||
- Descriptive names that indicate purpose (AppHeaderDropdown vs Dropdown)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
coreui-free-vue-admin-template/
|
||||
├── public/ # Static assets served directly
|
||||
├── src/
|
||||
│ ├── assets/ # Images, logos, icons
|
||||
│ │ ├── brand/ # Logo components
|
||||
│ │ └── images/ # Image files
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ │ ├── AppHeader.vue
|
||||
│ │ ├── AppSidebar.vue
|
||||
│ │ ├── AppFooter.vue
|
||||
│ │ ├── AppContent.vue
|
||||
│ │ ├── AppBreadcrumb.vue
|
||||
│ │ └── AppHeaderDropdown.vue
|
||||
│ ├── layouts/ # Layout components
|
||||
│ │ └── DefaultLayout.vue
|
||||
│ ├── views/ # Page components
|
||||
│ │ ├── dashboard/ # Dashboard page
|
||||
│ │ ├── base/ # Base UI components examples
|
||||
│ │ ├── buttons/ # Button examples
|
||||
│ │ ├── forms/ # Form examples
|
||||
│ │ ├── charts/ # Chart examples
|
||||
│ │ ├── icons/ # Icon examples
|
||||
│ │ ├── notifications/ # Notification examples
|
||||
│ │ ├── widgets/ # Widget examples
|
||||
│ │ └── pages/ # Auth & error pages
|
||||
│ ├── router/ # Router configuration
|
||||
│ │ └── index.js # Route definitions
|
||||
│ ├── stores/ # Pinia stores
|
||||
│ ├── scss/ # Stylesheets
|
||||
│ │ ├── style.scss # Main stylesheet
|
||||
│ │ └── _custom.scss # Custom overrides
|
||||
│ ├── App.vue # Main app component
|
||||
│ ├── main.js # Entry point
|
||||
│ └── _nav.js # Navigation config
|
||||
├── index.html # HTML template
|
||||
├── vite.config.mjs # Vite configuration
|
||||
├── eslint.config.mjs # ESLint configuration
|
||||
├── package.json # Dependencies
|
||||
└── README.md # Documentation
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
**Starting Development**:
|
||||
```bash
|
||||
npm install # Install dependencies
|
||||
npm run dev # Start dev server (http://localhost:3000)
|
||||
npm run build # Build for production
|
||||
npm run preview # Preview production build
|
||||
npm run lint # Run ESLint
|
||||
```
|
||||
|
||||
**Adding a New Page**:
|
||||
1. Create component in `src/views/[feature]/ComponentName.vue`
|
||||
2. Add route to `src/router/index.js`
|
||||
3. Add navigation item to `src/_nav.js` (if needed)
|
||||
4. Import and use CoreUI components from '@coreui/vue'
|
||||
|
||||
**Creating Components**:
|
||||
```vue
|
||||
<template>
|
||||
<CCard>
|
||||
<CCardHeader>{{ title }}</CCardHeader>
|
||||
<CCardBody>
|
||||
<slot />
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Component-specific styles */
|
||||
</style>
|
||||
```
|
||||
|
||||
**Using Composition API**:
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useStore } from '@/stores/main'
|
||||
|
||||
const data = ref([])
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
const filteredData = computed(() => {
|
||||
return data.value.filter(item => item.active)
|
||||
})
|
||||
|
||||
watch(data, (newValue) => {
|
||||
console.log('Data changed:', newValue)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Fetch data or run initialization
|
||||
})
|
||||
|
||||
const navigate = () => {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Code Quality
|
||||
|
||||
**Linting**:
|
||||
- ESLint configuration in `eslint.config.mjs`
|
||||
- Prettier integration for code formatting
|
||||
- Vue-specific rules with eslint-plugin-vue
|
||||
- Run `npm run lint` before committing
|
||||
|
||||
**Best Practices**:
|
||||
- Always define props with types and validation
|
||||
- Use meaningful component and variable names
|
||||
- Keep components small and focused
|
||||
- Extract complex logic into composables
|
||||
- Use Vue DevTools for debugging
|
||||
- Test in both light and dark themes
|
||||
- Ensure responsive design works on all screen sizes
|
||||
|
||||
**Git Commits**:
|
||||
Follow conventional commit format:
|
||||
- feat: New feature
|
||||
- fix: Bug fix
|
||||
- docs: Documentation changes
|
||||
- style: Code style changes (formatting)
|
||||
- refactor: Code refactoring
|
||||
- test: Adding tests
|
||||
- chore: Maintenance tasks
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Lazy Loading Routes**:
|
||||
```javascript
|
||||
const Dashboard = () => import('./views/dashboard/Dashboard.vue')
|
||||
```
|
||||
|
||||
**Composables** (Custom hooks):
|
||||
```javascript
|
||||
// composables/useFetch.js
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useFetch(url) {
|
||||
const data = ref(null)
|
||||
const error = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(json => {
|
||||
data.value = json
|
||||
})
|
||||
.catch(err => {
|
||||
error.value = err
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
return { data, error, loading }
|
||||
}
|
||||
```
|
||||
|
||||
**Pinia Store**:
|
||||
```javascript
|
||||
// stores/main.js
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useStore = defineStore('main', () => {
|
||||
const sidebarVisible = ref(true)
|
||||
const theme = ref('light')
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarVisible.value = !sidebarVisible.value
|
||||
}
|
||||
|
||||
function setTheme(newTheme) {
|
||||
theme.value = newTheme
|
||||
}
|
||||
|
||||
return { sidebarVisible, theme, toggleSidebar, setTheme }
|
||||
})
|
||||
```
|
||||
|
||||
**Navigation Configuration** (`_nav.js`):
|
||||
```javascript
|
||||
export default [
|
||||
{
|
||||
component: 'CNavItem',
|
||||
name: 'Dashboard',
|
||||
to: '/dashboard',
|
||||
icon: 'cil-speedometer',
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
## AI Assistant Guidelines
|
||||
|
||||
**What AI Should Do**:
|
||||
- Generate code using CoreUI Vue components
|
||||
- Follow existing code style and conventions
|
||||
- Use Composition API with <script setup>
|
||||
- Implement responsive designs
|
||||
- Define props with proper validation
|
||||
- Follow the project's file organization
|
||||
- Use existing utility functions and helpers
|
||||
- Suggest performance optimizations when appropriate
|
||||
|
||||
**What AI Should NOT Do**:
|
||||
- Use Options API (use Composition API instead)
|
||||
- Import components from libraries other than CoreUI/Vue
|
||||
- Suggest Tailwind CSS or other CSS frameworks
|
||||
- Ignore ESLint/Prettier rules
|
||||
- Create files outside the src/ directory structure
|
||||
- Modify build configuration without clear reason
|
||||
- Use deprecated Vue patterns
|
||||
|
||||
## External Dependencies
|
||||
|
||||
**Core Libraries**:
|
||||
- @coreui/coreui: ^5.6.1 - CoreUI CSS framework
|
||||
- @coreui/vue: ^5.8.0 - CoreUI Vue components
|
||||
- @coreui/icons-vue: 2.2.0 - CoreUI icons for Vue
|
||||
- vue: ^3.5.31 - Vue framework
|
||||
- vue-router: ^5.0.4 - Routing library
|
||||
- pinia: ^3.0.4 - State management
|
||||
|
||||
**Additional Libraries**:
|
||||
- chart.js: ^4.5.1 - Charting library
|
||||
- @coreui/vue-chartjs: ^3.0.0 - CoreUI Chart.js wrapper for Vue
|
||||
- simplebar-vue: ^2.4.2 - Custom scrollbars
|
||||
- @coreui/utils: ^2.0.2 - Utility functions
|
||||
|
||||
## Browser Support
|
||||
|
||||
Modern browsers with ES6+ support:
|
||||
- Chrome (latest)
|
||||
- Firefox (latest)
|
||||
- Safari (latest)
|
||||
- Edge (latest)
|
||||
|
||||
## Resources
|
||||
|
||||
- CoreUI Vue Documentation: https://coreui.io/vue/docs/
|
||||
- CoreUI Components: https://coreui.io/vue/docs/components/
|
||||
- Vue 3 Documentation: https://vuejs.org/
|
||||
- Vue Router: https://router.vuejs.org/
|
||||
- Pinia: https://pinia.vuejs.org/
|
||||
- Vite: https://vitejs.dev/
|
||||
|
||||
---
|
||||
|
||||
Remember: This is a Vue 3 application using CoreUI Vue components. Always check CoreUI Vue documentation for component APIs and usage examples. Keep the code clean, maintainable, and following Vue 3 best practices with Composition API.
|
||||
@@ -2,6 +2,7 @@
|
||||
dist/
|
||||
node_modules/
|
||||
tests/e2e/reports/
|
||||
.vite/
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
|
||||
+716
@@ -0,0 +1,716 @@
|
||||
# CoreUI Free Vue Admin Template - Architecture
|
||||
|
||||
This document provides a comprehensive overview of the CoreUI Free Vue Admin Template architecture, design patterns, and technical implementation details.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Project Overview](#project-overview)
|
||||
- [Technology Stack](#technology-stack)
|
||||
- [Architectural Pattern](#architectural-pattern)
|
||||
- [Directory Structure](#directory-structure)
|
||||
- [Core Components](#core-components)
|
||||
- [Routing System](#routing-system)
|
||||
- [State Management](#state-management)
|
||||
- [Styling Architecture](#styling-architecture)
|
||||
- [Build System](#build-system)
|
||||
- [Performance Optimizations](#performance-optimizations)
|
||||
- [Browser Support](#browser-support)
|
||||
|
||||
## Project Overview
|
||||
|
||||
The CoreUI Free Vue Admin Template is a professional admin dashboard built on Vue 3, CoreUI Vue components, and Bootstrap 5. It follows modern Vue patterns with Composition API, script setup syntax, and a component-based architecture.
|
||||
|
||||
**Key Features**:
|
||||
- Single Page Application (SPA) with client-side routing
|
||||
- Responsive design with Bootstrap 5 grid system
|
||||
- Dark/Light theme support with automatic detection
|
||||
- Lazy loading and code splitting for optimal performance
|
||||
- Pinia-based state management
|
||||
- Modular and extensible component architecture
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Frontend Core
|
||||
|
||||
| Technology | Version | Purpose |
|
||||
|------------|---------|---------|
|
||||
| Vue | 3.5.31 | Progressive framework for building user interfaces |
|
||||
| Vue Router | 5.0.4 | Official router for Vue.js |
|
||||
| Pinia | 3.0.4 | Official state management library for Vue |
|
||||
|
||||
### UI Framework
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| @coreui/coreui | 5.6.1 | CoreUI CSS framework based on Bootstrap 5 |
|
||||
| @coreui/vue | 5.8.0 | CoreUI Vue components |
|
||||
| @coreui/icons | 3.0.1 | CoreUI icon set |
|
||||
| @coreui/icons-vue | 2.2.0 | CoreUI icons as Vue components |
|
||||
| @coreui/utils | 2.0.2 | Utility functions for CoreUI |
|
||||
| simplebar-vue | 2.4.2 | Custom scrollbar component |
|
||||
|
||||
### Data Visualization
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| Chart.js | 4.5.1 | HTML5 charting library |
|
||||
| @coreui/chartjs | 4.2.0 | CoreUI Chart.js themes and defaults |
|
||||
| @coreui/vue-chartjs | 3.0.0 | Vue wrapper for Chart.js with CoreUI styling |
|
||||
|
||||
### Build Tools & Development
|
||||
|
||||
| Tool | Version | Purpose |
|
||||
|------|---------|---------|
|
||||
| Vite | 8.0.3 | Fast build tool and dev server with HMR |
|
||||
| @vitejs/plugin-vue | 6.0.5 | Vite plugin for Vue 3 |
|
||||
| Sass | 1.98.0 | CSS preprocessor for styling |
|
||||
| PostCSS | 8.5.8 | CSS transformation with autoprefixer |
|
||||
| Autoprefixer | 10.4.27 | Automatic vendor prefixing |
|
||||
| ESLint | 9.39.4 | JavaScript linting and code quality |
|
||||
| eslint-plugin-vue | 10.8.0 | Vue-specific ESLint rules |
|
||||
|
||||
### Utilities
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| @popperjs/core | 2.11.8 | Tooltip and popover positioning |
|
||||
|
||||
## Architectural Pattern
|
||||
|
||||
### Component-Based Architecture
|
||||
|
||||
The application follows a **Composition API architecture** with script setup syntax:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Application (App.vue) │
|
||||
│ - Router View │
|
||||
│ - Theme Management │
|
||||
│ - Route Configuration │
|
||||
└──────────────────────────────────────────┘
|
||||
↓
|
||||
┌───────────────┴────────────────┐
|
||||
│ │
|
||||
┌───▼────┐ ┌────────▼───────┐
|
||||
│ Public │ │ Protected │
|
||||
│ Routes │ │ Routes │
|
||||
│ │ │(DefaultLayout) │
|
||||
│ Login │ └───────┬────────┘
|
||||
│Register│ │
|
||||
│ 404 │ ┌───────────┼────────────┐
|
||||
│ 500 │ │ │ │
|
||||
└────────┘ ┌────▼────┐ ┌────▼─────┐ ┌────▼─────┐
|
||||
│AppHeader│ │AppSidebar│ │AppContent│
|
||||
└─────────┘ └──────────┘ └────┬─────┘
|
||||
│
|
||||
┌───────▼─────────┐
|
||||
│ View Components │
|
||||
│ (Dashboard, │
|
||||
│ Forms, etc.) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### Single Page Application (SPA) Pattern
|
||||
|
||||
The template uses client-side routing with createWebHashHistory:
|
||||
1. **Initial Load**: HTML shell loads, Vue initializes
|
||||
2. **Route Matching**: Vue Router matches URL to component
|
||||
3. **Lazy Loading**: Component bundles load on-demand
|
||||
4. **Rendering**: Component renders with layout wrapper
|
||||
5. **Navigation**: Client-side transitions without page reload
|
||||
|
||||
### State Management Pattern
|
||||
|
||||
Pinia manages global application state:
|
||||
|
||||
```javascript
|
||||
Store (stores/)
|
||||
├── sidebarVisible (boolean)
|
||||
├── sidebarUnfoldable (boolean)
|
||||
└── theme (string: light/dark/auto)
|
||||
```
|
||||
|
||||
Component-level state uses Vue's Composition API (ref, reactive).
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
coreui-free-vue-admin-template/
|
||||
│
|
||||
├── public/ # Static assets (served as-is)
|
||||
│ ├── favicon.ico
|
||||
│ └── robots.txt
|
||||
│
|
||||
├── src/ # Source code
|
||||
│ │
|
||||
│ ├── assets/ # Application assets
|
||||
│ │ ├── brand/ # Logo components (logo.js, sygnet.js)
|
||||
│ │ └── images/ # Image files (avatars, etc.)
|
||||
│ │
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ │ ├── AppBreadcrumb.vue # Breadcrumb navigation
|
||||
│ │ ├── AppContent.vue # Main content area wrapper
|
||||
│ │ ├── AppFooter.vue # Footer component
|
||||
│ │ ├── AppHeader.vue # Header component
|
||||
│ │ ├── AppHeaderDropdown.vue # User dropdown menu
|
||||
│ │ ├── AppSidebar.vue # Sidebar navigation
|
||||
│ │ ├── DocsComponents.vue # Documentation component showcase
|
||||
│ │ └── DocsExample.vue # Code example wrapper
|
||||
│ │
|
||||
│ ├── layouts/ # Layout wrapper components
|
||||
│ │ └── DefaultLayout.vue # Main application layout
|
||||
│ │
|
||||
│ ├── views/ # Page/view components
|
||||
│ │ ├── dashboard/ # Dashboard page
|
||||
│ │ │ ├── Dashboard.vue
|
||||
│ │ │ └── MainChart.vue
|
||||
│ │ ├── base/ # Base UI component examples
|
||||
│ │ │ ├── Accordion.vue
|
||||
│ │ │ ├── Breadcrumbs.vue
|
||||
│ │ │ ├── Cards.vue
|
||||
│ │ │ ├── Carousels.vue
|
||||
│ │ │ ├── Chips.vue
|
||||
│ │ │ ├── Collapses.vue
|
||||
│ │ │ ├── ListGroups.vue
|
||||
│ │ │ ├── Navs.vue
|
||||
│ │ │ ├── Paginations.vue
|
||||
│ │ │ ├── Placeholders.vue
|
||||
│ │ │ ├── Popovers.vue
|
||||
│ │ │ ├── Progress.vue
|
||||
│ │ │ ├── Spinners.vue
|
||||
│ │ │ ├── Tables.vue
|
||||
│ │ │ ├── Tabs.vue
|
||||
│ │ │ └── Tooltips.vue
|
||||
│ │ ├── buttons/ # Button examples
|
||||
│ │ ├── charts/ # Chart examples
|
||||
│ │ ├── forms/ # Form examples
|
||||
│ │ │ └── ChipInput.vue
|
||||
│ │ ├── icons/ # Icon examples
|
||||
│ │ ├── notifications/ # Notification examples
|
||||
│ │ ├── widgets/ # Widget examples
|
||||
│ │ ├── theme/ # Theme examples
|
||||
│ │ └── pages/ # Special pages
|
||||
│ │ ├── Login.vue # Login page
|
||||
│ │ ├── Register.vue # Registration page
|
||||
│ │ ├── Page404.vue # 404 error page
|
||||
│ │ └── Page500.vue # 500 error page
|
||||
│ │
|
||||
│ ├── router/ # Router configuration
|
||||
│ │ └── index.js # Route definitions
|
||||
│ │
|
||||
│ ├── stores/ # Pinia stores (if created)
|
||||
│ │
|
||||
│ ├── scss/ # Global stylesheets
|
||||
│ │ ├── style.scss # Main stylesheet (imports CoreUI)
|
||||
│ │ └── _custom.scss # Custom style overrides
|
||||
│ │
|
||||
│ ├── App.vue # Root application component
|
||||
│ ├── main.js # Application entry point
|
||||
│ └── _nav.js # Sidebar navigation configuration
|
||||
│
|
||||
├── node_modules/ # Dependencies
|
||||
├── index.html # HTML entry point
|
||||
├── vite.config.mjs # Vite build configuration
|
||||
├── eslint.config.mjs # ESLint configuration
|
||||
├── package.json # Project metadata and dependencies
|
||||
├── .browserslistrc # Browser compatibility targets
|
||||
├── .editorconfig # Editor configuration
|
||||
└── README.md # Project documentation
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### Application Component (App.vue)
|
||||
|
||||
The root component that:
|
||||
- Sets up router-view for component rendering
|
||||
- Manages theme initialization
|
||||
- Provides the application shell
|
||||
|
||||
**Key Features**:
|
||||
- Theme detection and persistence
|
||||
- Suspense boundaries for lazy-loaded routes
|
||||
- Global error handling boundaries
|
||||
|
||||
### Layout System
|
||||
|
||||
#### DefaultLayout (layouts/DefaultLayout.vue)
|
||||
|
||||
The main application layout wrapper that composes:
|
||||
- **AppSidebar**: Collapsible navigation sidebar
|
||||
- **AppHeader**: Top navigation bar with breadcrumbs and user menu
|
||||
- **AppContent**: Main content area with routing
|
||||
- **AppFooter**: Footer with version and links
|
||||
|
||||
**Responsibility**: Provides consistent layout structure for authenticated views.
|
||||
|
||||
#### Navigation Components
|
||||
|
||||
**AppSidebar** (`components/AppSidebar.vue`):
|
||||
- Renders collapsible sidebar
|
||||
- Uses Pinia for show/hide state
|
||||
- Integrates with _nav.js for menu structure
|
||||
- Includes branding section
|
||||
|
||||
**AppHeader** (`components/AppHeader.vue`):
|
||||
- Fixed top navigation bar
|
||||
- Sidebar toggle button
|
||||
- Breadcrumb navigation
|
||||
- User dropdown menu
|
||||
- Theme switcher
|
||||
|
||||
### View Components
|
||||
|
||||
View components are page-level components that:
|
||||
- Render specific application features (Dashboard, Forms, Charts)
|
||||
- Use CoreUI Vue components for UI
|
||||
- Connect to Pinia when needed for global state
|
||||
- Implement business logic and data fetching
|
||||
|
||||
**Example Structure**:
|
||||
```vue
|
||||
<template>
|
||||
<CCard>
|
||||
<CCardBody>
|
||||
{{ data }}
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const data = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
// Fetch dashboard data
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Routing System
|
||||
|
||||
### Vue Router v5
|
||||
|
||||
The application uses Vue Router for declarative routing:
|
||||
|
||||
**Configuration** (`router/index.js`):
|
||||
```javascript
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: DefaultLayout,
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard/Dashboard.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/pages',
|
||||
redirect: '/pages/404',
|
||||
name: 'Pages',
|
||||
component: {
|
||||
render() {
|
||||
return h(resolveComponent('router-view'))
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '404',
|
||||
name: 'Page404',
|
||||
component: () => import('@/views/pages/Page404'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
scrollBehavior() {
|
||||
return { top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
export default router
|
||||
```
|
||||
|
||||
### Lazy Loading & Code Splitting
|
||||
|
||||
All routes use dynamic imports for lazy loading:
|
||||
|
||||
```javascript
|
||||
component: () => import('./views/dashboard/Dashboard.vue')
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Smaller initial bundle size
|
||||
- Faster first page load
|
||||
- Components load only when navigated to
|
||||
- Automatic code splitting by Vite
|
||||
|
||||
### Navigation Configuration
|
||||
|
||||
Navigation structure defined in `_nav.js`:
|
||||
|
||||
```javascript
|
||||
export default [
|
||||
{
|
||||
component: 'CNavItem',
|
||||
name: 'Dashboard',
|
||||
to: '/dashboard',
|
||||
icon: 'cil-speedometer',
|
||||
badge: {
|
||||
color: 'info',
|
||||
text: 'NEW',
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'CNavGroup',
|
||||
name: 'Base',
|
||||
icon: 'cil-puzzle',
|
||||
items: [
|
||||
{
|
||||
component: 'CNavItem',
|
||||
name: 'Accordion',
|
||||
to: '/base/accordion',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Pinia Store Architecture
|
||||
|
||||
Pinia uses the Composition API pattern:
|
||||
|
||||
**Store Example** (`stores/main.js`):
|
||||
|
||||
```javascript
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useStore = defineStore('main', () => {
|
||||
const sidebarVisible = ref(true)
|
||||
const sidebarUnfoldable = ref(false)
|
||||
const theme = ref('light')
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarVisible.value = !sidebarVisible.value
|
||||
}
|
||||
|
||||
function setSidebarUnfoldable(value) {
|
||||
sidebarUnfoldable.value = value
|
||||
}
|
||||
|
||||
function setTheme(value) {
|
||||
theme.value = value
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarVisible,
|
||||
sidebarUnfoldable,
|
||||
theme,
|
||||
toggleSidebar,
|
||||
setSidebarUnfoldable,
|
||||
setTheme,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### State Usage in Components
|
||||
|
||||
**Reading State**:
|
||||
```vue
|
||||
<script setup>
|
||||
import { useStore } from '@/stores/main'
|
||||
|
||||
const store = useStore()
|
||||
// Access: store.sidebarVisible
|
||||
</script>
|
||||
```
|
||||
|
||||
**Updating State**:
|
||||
```vue
|
||||
<script setup>
|
||||
import { useStore } from '@/stores/main'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const toggleSidebar = () => {
|
||||
store.toggleSidebar()
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Styling Architecture
|
||||
|
||||
### Sass/SCSS Structure
|
||||
|
||||
**Main Stylesheet** (`src/scss/style.scss`):
|
||||
```scss
|
||||
@use "@coreui/coreui/scss/coreui" as * with (
|
||||
$enable-deprecation-messages: false
|
||||
);
|
||||
|
||||
// Custom variables and overrides
|
||||
@import 'custom';
|
||||
```
|
||||
|
||||
**Custom Overrides** (`src/scss/_custom.scss`):
|
||||
```scss
|
||||
// Override CoreUI/Bootstrap variables
|
||||
$primary: #321fdb;
|
||||
$secondary: #ced2d8;
|
||||
|
||||
// Custom styles
|
||||
.my-custom-class {
|
||||
// styles
|
||||
}
|
||||
```
|
||||
|
||||
### CSS Custom Properties (CSS Variables)
|
||||
|
||||
CoreUI uses CSS custom properties for theming:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--cui-primary: #321fdb;
|
||||
--cui-secondary: #ced2d8;
|
||||
--cui-body-bg: #ebedef;
|
||||
--cui-body-color: #4f5d73;
|
||||
}
|
||||
|
||||
[data-coreui-theme="dark"] {
|
||||
--cui-body-bg: #2b3035;
|
||||
--cui-body-color: #b4bac0;
|
||||
}
|
||||
```
|
||||
|
||||
**Usage in Components**:
|
||||
```vue
|
||||
<template>
|
||||
<div :style="{ backgroundColor: 'var(--cui-primary)' }">Content</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Component Styling
|
||||
|
||||
**Scoped Styles**:
|
||||
```vue
|
||||
<style scoped>
|
||||
.my-component {
|
||||
padding: 1rem;
|
||||
background-color: var(--cui-light);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**Class Bindings**:
|
||||
```vue
|
||||
<template>
|
||||
<button :class="['btn', { 'btn-primary': isPrimary, 'active': isActive }]">
|
||||
Click
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Bootstrap Utilities**:
|
||||
```vue
|
||||
<template>
|
||||
<CCard class="mb-4 shadow-sm">
|
||||
<CCardBody class="p-4 d-flex justify-content-between">
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Build System
|
||||
|
||||
### Vite Configuration
|
||||
|
||||
**File**: `vite.config.mjs`
|
||||
|
||||
```javascript
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import autoprefixer from 'autoprefixer'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [autoprefixer()],
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Build Process
|
||||
|
||||
**Development Build**:
|
||||
1. Vite starts dev server on port 3000
|
||||
2. esbuild compiles Vue SFC components
|
||||
3. PostCSS processes Sass/SCSS with autoprefixer
|
||||
4. Hot Module Replacement (HMR) for instant updates
|
||||
|
||||
**Production Build**:
|
||||
1. `vite build` command
|
||||
2. Code minification and tree-shaking
|
||||
3. Asset optimization (images, fonts)
|
||||
4. CSS extraction and minification
|
||||
5. Source maps generation
|
||||
6. Output to `dist/` directory
|
||||
|
||||
**Build Output**:
|
||||
```
|
||||
dist/
|
||||
├── assets/
|
||||
│ ├── index-[hash].js # Main bundle
|
||||
│ ├── [component]-[hash].js # Lazy-loaded chunks
|
||||
│ └── index-[hash].css # Extracted CSS
|
||||
├── index.html # HTML entry
|
||||
└── favicon.ico # Static assets
|
||||
```
|
||||
|
||||
### Code Splitting Strategy
|
||||
|
||||
**Automatic Splitting**:
|
||||
- Each lazy-loaded route becomes a separate chunk
|
||||
- Vendor libraries (Vue, CoreUI) in separate vendor chunk
|
||||
- Dynamic imports create split points
|
||||
|
||||
**Manual Splitting** (if needed):
|
||||
```javascript
|
||||
const HeavyComponent = () => import(/* webpackChunkName: "heavy" */ './HeavyComponent.vue')
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Implemented Optimizations
|
||||
|
||||
1. **Lazy Loading**: All routes lazy-loaded with dynamic imports
|
||||
2. **Code Splitting**: Separate bundles per route
|
||||
3. **Tree Shaking**: Unused code eliminated by Vite
|
||||
4. **Asset Optimization**: Images and fonts optimized
|
||||
5. **CSS Extraction**: Separate CSS bundle for caching
|
||||
6. **Hash-based Caching**: File names include content hash
|
||||
|
||||
### Component Optimization
|
||||
|
||||
**v-memo** for expensive renders:
|
||||
```vue
|
||||
<template>
|
||||
<div v-memo="[valueA, valueB]">
|
||||
<!-- Heavy rendering -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**computed** for derived values:
|
||||
```vue
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const sortedData = computed(() => {
|
||||
return data.value.sort((a, b) => a.value - b.value)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Bundle Size Management
|
||||
|
||||
**Strategies**:
|
||||
- Use named imports: `import { CButton } from '@coreui/vue'`
|
||||
- Avoid importing entire libraries
|
||||
- Check bundle size with `npm run build`
|
||||
- Use Vite's rollup visualizer for analysis
|
||||
|
||||
## Browser Support
|
||||
|
||||
### Target Browsers
|
||||
|
||||
Defined in `.browserslistrc`:
|
||||
```
|
||||
> 0.5%
|
||||
last 2 versions
|
||||
Firefox ESR
|
||||
not dead
|
||||
not IE 11
|
||||
```
|
||||
|
||||
### Polyfills
|
||||
|
||||
Modern browsers support Vue 3 natively. No additional polyfills needed for ES6+ features.
|
||||
|
||||
### Progressive Enhancement
|
||||
|
||||
- Modern features with fallbacks
|
||||
- CSS Grid with flexbox fallback
|
||||
- Modern color modes with theme classes
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Content Security Policy**: Configure CSP headers
|
||||
2. **XSS Prevention**: Vue escapes content by default
|
||||
3. **Dependency Auditing**: Run `npm audit` regularly
|
||||
4. **Environment Variables**: Use `.env` files (not committed)
|
||||
5. **HTTPS**: Serve over HTTPS in production
|
||||
|
||||
### Vue Security
|
||||
|
||||
- Avoid `v-html` unless necessary and sanitize content
|
||||
- Validate user input before rendering
|
||||
- Use prop validation for type safety
|
||||
- Keep dependencies updated
|
||||
|
||||
## Deployment
|
||||
|
||||
### Static Hosting
|
||||
|
||||
The application builds to static files suitable for:
|
||||
- Netlify
|
||||
- Vercel
|
||||
- GitHub Pages
|
||||
- AWS S3 + CloudFront
|
||||
- Any static file server
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Output in `dist/` directory ready for deployment.
|
||||
|
||||
### HashRouter for Static Hosts
|
||||
|
||||
Uses createWebHashHistory for GitHub Pages compatibility:
|
||||
- URLs: `https://example.com/#/dashboard`
|
||||
- No server-side routing configuration needed
|
||||
- Works with any static host
|
||||
|
||||
---
|
||||
|
||||
This architecture provides a solid foundation for building modern, performant admin dashboards with Vue 3 and CoreUI. The modular structure allows for easy extension and customization while maintaining code quality and best practices.
|
||||
+1113
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2025 creativeLabs Łukasz Holeczek.
|
||||
Copyright (c) 2026 creativeLabs Łukasz Holeczek.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# CoreUI Free Vue Admin Template [](https://twitter.com/intent/tweet?text=CoreUI%20-%20Free%Vue%204%20Admin%20Template%20&url=https://coreui.io&hashtags=bootstrap,admin,template,dashboard,panel,free,angular,react,vue)
|
||||
# CoreUI Free Vue Admin Template - Built for AI-Assisted Development [](https://twitter.com/intent/tweet?text=CoreUI%20-%20Free%Vue%204%20Admin%20Template%20&url=https://coreui.io&hashtags=bootstrap,admin,template,dashboard,panel,free,angular,react,vue)
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/coreui/coreui)
|
||||
@@ -29,6 +29,7 @@ CoreUI is meant to be the UX game changer. Pure & transparent code is devoid of
|
||||
* [Installation](#installation)
|
||||
* [Basic usage](#basic-usage)
|
||||
* [What's included](#whats-included)
|
||||
* [AI-Friendly Development](#ai-friendly-development)
|
||||
* [Documentation](#documentation)
|
||||
* [Components](#components)
|
||||
* [Versioning](#versioning)
|
||||
@@ -135,6 +136,43 @@ coreui-free-vue-admin-template
|
||||
└── vite.config.mjs
|
||||
```
|
||||
|
||||
## AI-Friendly Development
|
||||
|
||||
This template is designed to work seamlessly with AI coding assistants like Cursor, GitHub Copilot, and Claude Code. We've included comprehensive documentation and configuration files to help AI understand the project structure and conventions.
|
||||
|
||||
### Features for AI Development
|
||||
|
||||
- **`.cursorrules`**: Complete AI assistant configuration with project conventions, technology stack, and coding patterns
|
||||
- **`ARCHITECTURE.md`**: Detailed technical architecture documentation covering components, routing, state management, and build system
|
||||
- **`DEVELOPMENT.md`**: Comprehensive development guide with setup instructions, workflows, and best practices
|
||||
- **JSDoc Comments**: Main Vue components include documentation for better AI understanding
|
||||
|
||||
### Getting Started with AI Assistants
|
||||
|
||||
1. **Open the project** in your AI-powered IDE (VS Code with Cursor, GitHub Copilot, or Claude Code)
|
||||
2. **Review `.cursorrules`** to understand the project conventions
|
||||
3. **Ask your AI assistant** to help you build features - it will automatically follow the project patterns
|
||||
4. **Use natural language** to describe components, pages, or features you want to add
|
||||
|
||||
### What AI Assistants Know About This Project
|
||||
|
||||
Your AI assistant understands:
|
||||
- **Component Library**: Always use CoreUI Vue components (never Tailwind, Vuetify, or other libraries)
|
||||
- **Code Style**: Vue 3 Composition API with `<script setup>` syntax, Prettier formatting, ESLint rules
|
||||
- **Architecture**: Single Page Application with Vue Router, Pinia for state management, Vite for building
|
||||
- **File Organization**: Where to create components, views, routes, and styles
|
||||
- **Common Patterns**: Lazy loading, composables, navigation configuration, theming
|
||||
|
||||
### Example AI Prompts
|
||||
|
||||
Try asking your AI assistant:
|
||||
- "Create a new products page with a table showing product name, price, and status"
|
||||
- "Add a user profile form with validation"
|
||||
- "Create a chart showing monthly sales data"
|
||||
- "Add a new navigation item for the settings page"
|
||||
|
||||
For more information, see the [DEVELOPMENT.md](DEVELOPMENT.md) guide.
|
||||
|
||||
## Documentation
|
||||
|
||||
The documentation for the CoreUI Admin Template is hosted at our website [CoreUI for Vue](https://coreui.io/vue/docs/templates/installation.html)
|
||||
@@ -155,6 +193,8 @@ CoreUI Vue.js Admin Templates are built on top of CoreUI and CoreUI PRO UI compo
|
||||
- [Vue Card](https://coreui.io/vue/docs/components/card.html)
|
||||
- [Vue Carousel](https://coreui.io/vue/docs/components/carousel.html)
|
||||
- [Vue Checkbox](https://coreui.io/vue/docs/forms/checkbox.html)
|
||||
- [Vue Chip](https://coreui.io/vue/docs/components/chip.html)
|
||||
- [Vue Chip Input](https://coreui.io/vue/docs/forms/chip-input.html)
|
||||
- [Vue Close Button](https://coreui.io/vue/docs/components/close-button.html)
|
||||
- [Vue Collapse](https://coreui.io/vue/docs/components/collapse.html)
|
||||
- [Vue Date Picker](https://coreui.io/vue/docs/forms/date-picker.html) **PRO**
|
||||
@@ -231,6 +271,6 @@ CoreUI is an MIT-licensed open source project and is completely free to use. How
|
||||
|
||||
## Copyright and License
|
||||
|
||||
copyright 2025 creativeLabs Łukasz Holeczek.
|
||||
copyright 2026 creativeLabs Łukasz Holeczek.
|
||||
|
||||
Code released under [the MIT license](https://github.com/coreui/coreui-free-react-admin-template/blob/main/LICENSE).
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Tenant API Integration TODO
|
||||
|
||||
- [x] Review existing tenant API and UI navigation/routes (done in analysis)
|
||||
- [x] Add tenant service for API CRUD in `src/services/tenantService.js`
|
||||
- [x] Add tenant route in `src/router/index.js`
|
||||
- [x] Add tenant menu item in `src/_nav.js`
|
||||
- [x] Create tenant page `src/views/tenants/Tenants.vue` and integrate list API
|
||||
- [x] Verify integration consistency
|
||||
|
||||
# Tenant CRUD UI TODO
|
||||
|
||||
- [x] Upgrade `src/views/tenants/Tenants.vue` to full CRUD page
|
||||
- [x] Integrate create/read/update/delete flows with `tenantService`
|
||||
- [x] Add form validation for `tenant_code`, `tenant_name`, `status`
|
||||
- [x] Add detail modal and delete confirmation
|
||||
- [x] Final consistency review
|
||||
|
||||
# BaseResponsiveDataView Header/Table Alignment TODO
|
||||
|
||||
- [x] Refactor desktop table header structure to follow row list layout
|
||||
- [x] Align desktop header columns with rendered desktop columns
|
||||
- [x] Remove desktop action spacer and use consistent action column
|
||||
- [x] Keep mobile behavior unchanged
|
||||
- [x] Mark tasks complete after implementation
|
||||
|
||||
# GroupMenus Responsive Style Alignment TODO
|
||||
|
||||
- [x] Review `GroupMenus.vue` and map existing actions/data to `BaseResponsiveDataView`
|
||||
- [x] Refactor GroupMenus list section to use `BaseResponsiveDataView`
|
||||
- [x] Keep existing action flows (detail, detail menu, edit, hapus) working via action handler
|
||||
- [x] Add scoped container/header styling pattern similar to `Tenants.vue`
|
||||
- [x] Final consistency review
|
||||
|
||||
# BaseResponsiveDataView Desktop Column Proportion TODO
|
||||
|
||||
- [ ] Review desktop grid logic in `BaseResponsiveDataView.vue` and `_base-responsive-data-view.scss`
|
||||
- [ ] Make desktop column count dynamic (not hardcoded 6)
|
||||
- [ ] Make action column width proportional and consistent
|
||||
- [ ] Ensure desktop remains balanced even with only 2 data columns
|
||||
- [ ] Final consistency review for desktop/mobile behavior
|
||||
+34
-37
@@ -1,48 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
* CoreUI Free Vue.js Admin Template
|
||||
* @version v5.4.0
|
||||
* @link https://coreui.io/product/free-vue-admin-template/
|
||||
* Copyright (c) 2025 creativeLabs Łukasz Holeczek
|
||||
* Licensed under MIT (https://github.com/coreui/coreui-free-vue-admin-template/blob/main/LICENSE)
|
||||
-->
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,shrink-to-fit=no">
|
||||
<meta name="description" content="CoreUI Vue.js Admin Template">
|
||||
<meta name="author" content="creativeLabs Łukasz Holeczek">
|
||||
<title>CoreUI Vue.js Admin Template</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,shrink-to-fit=no" />
|
||||
<meta name="description" content="MANJAPRO - UI/UX" />
|
||||
<meta name="author" content="creativeLabs Łukasz Holeczek" />
|
||||
<title>MANJAPRO</title>
|
||||
<!-- favicons for all devices -->
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<!-- <link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png" />
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png" />
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png" />
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png" />
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/apple-icon-114x114.png" />
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120x120.png" />
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png" />
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png" /> -->
|
||||
<link rel="icon" type="image/png" href="/apple-icon.png" />
|
||||
<!-- <link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<script>
|
||||
const userMode = localStorage.getItem('coreui-free-vue-admin-template-theme');
|
||||
const systemDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (userMode === 'dark' || (userMode !== 'light' && systemDarkMode)) {
|
||||
document.documentElement.dataset.coreuiTheme = 'dark';
|
||||
}
|
||||
const userMode = localStorage.getItem('coreui-free-vue-admin-template-theme')
|
||||
const systemDarkMode =
|
||||
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
if (userMode === 'dark' || (userMode !== 'light' && systemDarkMode)) {
|
||||
document.documentElement.dataset.coreuiTheme = 'dark'
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
<strong
|
||||
>We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it
|
||||
to continue.</strong
|
||||
>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
|
||||
Generated
+4827
File diff suppressed because it is too large
Load Diff
+10
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coreui/coreui-free-vue-admin-template",
|
||||
"version": "5.4.0",
|
||||
"version": "5.5.0",
|
||||
"description": "CoreUI Free Vue Admin Template",
|
||||
"bugs": {
|
||||
"url": "https://github.com/coreui/coreui-free-vue-admin-template/issues"
|
||||
@@ -19,16 +19,22 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@coreui/chartjs": "^4.2.0",
|
||||
"@coreui/coreui": "^5.6.1",
|
||||
"@coreui/icons": "^3.0.1",
|
||||
"@coreui/coreui": "^5.8.0",
|
||||
"@coreui/icons": "^3.1.0",
|
||||
"@coreui/icons-vue": "2.2.0",
|
||||
"@coreui/utils": "^2.0.2",
|
||||
"@coreui/vue": "^5.8.0",
|
||||
"@coreui/vue": "^5.9.0",
|
||||
"@coreui/vue-chartjs": "^3.0.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"axios": "^1.18.1",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"maplibre-gl": "^3.6.2",
|
||||
"pinia": "^3.0.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"simplebar-vue": "^2.4.2",
|
||||
"sweetalert2": "^11.26.25",
|
||||
"vue": "^3.5.31",
|
||||
"vue-router": "^5.0.4"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 675">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#e9f2ff"/>
|
||||
<stop offset="1" stop-color="#d7e4f5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="675" fill="url(#bg)"/>
|
||||
<path d="M350 360 600 155l250 205v185H350z" fill="#fff" stroke="#6f8fb8" stroke-width="18" stroke-linejoin="round"/>
|
||||
<path d="M300 375 600 125l300 250" fill="none" stroke="#376da8" stroke-width="28" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="535" y="385" width="130" height="160" rx="8" fill="#9db8d8"/>
|
||||
<rect x="405" y="385" width="90" height="80" rx="8" fill="#c9dcf0"/>
|
||||
<rect x="705" y="385" width="90" height="80" rx="8" fill="#c9dcf0"/>
|
||||
<text x="600" y="620" text-anchor="middle" font-family="Arial, sans-serif" font-size="38" fill="#557393">Dokumentasi Customer</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 915 B |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
+16
@@ -1,9 +1,24 @@
|
||||
<script setup>
|
||||
/**
|
||||
* App.vue - Main Application Component
|
||||
*
|
||||
* This is the root component of the CoreUI Free Vue Admin Template.
|
||||
* It handles theme initialization and provides the router-view for all routes.
|
||||
*
|
||||
* Key responsibilities:
|
||||
* - Theme detection from URL parameters
|
||||
* - Theme persistence with localStorage
|
||||
* - Router view rendering for SPA navigation
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
import { onBeforeMount } from 'vue'
|
||||
import { useColorModes } from '@coreui/vue'
|
||||
|
||||
import { useThemeStore } from '@/stores/theme.js'
|
||||
import GlobalImagePreview from '@/components/GlobalImagePreview.vue'
|
||||
|
||||
// Initialize CoreUI color modes with local storage key
|
||||
const { isColorModeSet, setColorMode } = useColorModes(
|
||||
'coreui-free-vue-admin-template-theme',
|
||||
)
|
||||
@@ -32,6 +47,7 @@ onBeforeMount(() => {
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<GlobalImagePreview />
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
+106
-3
@@ -1,4 +1,28 @@
|
||||
export default [
|
||||
/**
|
||||
* _nav.js - Sidebar Navigation Configuration
|
||||
*
|
||||
* This file defines the structure and content of the sidebar navigation menu.
|
||||
* The navigation is rendered by AppSidebar component using CoreUI nav components.
|
||||
*
|
||||
* Navigation item types:
|
||||
* - CNavItem: Single navigation link
|
||||
* - CNavGroup: Expandable group of navigation items
|
||||
* - CNavTitle: Section title/divider
|
||||
*
|
||||
* Each item can have:
|
||||
* - component: CoreUI component type ('CNavItem', 'CNavGroup', 'CNavTitle')
|
||||
* - name: Display text
|
||||
* - to: Vue Router path (for CNavItem)
|
||||
* - icon: CoreUI icon name (from @coreui/icons)
|
||||
* - badge: Optional badge with color and text
|
||||
* - items: Array of child items (for CNavGroup)
|
||||
* - href: External link URL
|
||||
* - external: Boolean for external links
|
||||
*
|
||||
* @type {Array<Object>}
|
||||
*/
|
||||
|
||||
const baseNav = [
|
||||
{
|
||||
component: 'CNavItem',
|
||||
name: 'Dashboard',
|
||||
@@ -9,6 +33,54 @@ export default [
|
||||
text: 'NEW',
|
||||
},
|
||||
},
|
||||
// {
|
||||
// component: 'CNavTitle',
|
||||
// name: 'Ticketing',
|
||||
// },
|
||||
// {
|
||||
// component: 'CNavGroup',
|
||||
// name: 'Ticketing',
|
||||
// to: '/ticket',
|
||||
// icon: 'cil-layers',
|
||||
// items: [
|
||||
// {
|
||||
// component: 'CNavItem',
|
||||
// name: 'Ticket',
|
||||
// to: '/tickets/ticket',
|
||||
// icon: 'cil-notes',
|
||||
// },
|
||||
// {
|
||||
// component: 'CNavItem',
|
||||
// name: 'Ticket Type',
|
||||
// to: '/tickets/ticket-type',
|
||||
// icon: 'cil-list',
|
||||
// },
|
||||
// {
|
||||
// component: 'CNavItem',
|
||||
// name: 'Ticket Materials',
|
||||
// to: '/tickets/ticket-materials',
|
||||
// icon: 'cil-file',
|
||||
// },
|
||||
// {
|
||||
// component: 'CNavItem',
|
||||
// name: 'Ticket Incident',
|
||||
// to: '/tickets/ticket-incident',
|
||||
// icon: 'cil-Puzzle',
|
||||
// },
|
||||
// {
|
||||
// component: 'CNavItem',
|
||||
// name: 'Approved',
|
||||
// to: '/tickets/approved',
|
||||
// icon: 'cil-check',
|
||||
// },
|
||||
// {
|
||||
// component: 'CNavItem',
|
||||
// name: 'Rejected',
|
||||
// to: '/tickets/rejected',
|
||||
// icon: 'cil-ban',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
component: 'CNavTitle',
|
||||
name: 'Theme',
|
||||
@@ -17,7 +89,7 @@ export default [
|
||||
component: 'CNavItem',
|
||||
name: 'Colors',
|
||||
to: '/theme/colors',
|
||||
icon: 'cil-drop',
|
||||
icon: 'cil-square',
|
||||
},
|
||||
{
|
||||
component: 'CNavItem',
|
||||
@@ -65,6 +137,11 @@ export default [
|
||||
name: 'Carousels',
|
||||
to: '/base/carousels',
|
||||
},
|
||||
{
|
||||
component: 'CNavItem',
|
||||
name: 'Chips',
|
||||
to: '/base/chips',
|
||||
},
|
||||
{
|
||||
component: 'CNavItem',
|
||||
name: 'Collapses',
|
||||
@@ -148,7 +225,7 @@ export default [
|
||||
color: 'danger',
|
||||
text: 'PRO',
|
||||
},
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -205,6 +282,11 @@ export default [
|
||||
name: 'Checks & Radios',
|
||||
to: '/forms/checks-radios',
|
||||
},
|
||||
{
|
||||
component: 'CNavItem',
|
||||
name: 'Chip Input',
|
||||
to: '/forms/chip-input',
|
||||
},
|
||||
{
|
||||
component: 'CNavItem',
|
||||
name: 'Date Picker',
|
||||
@@ -372,6 +454,18 @@ export default [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
component: 'CNavItem',
|
||||
name: 'Users',
|
||||
to: '/users',
|
||||
icon: 'cil-user',
|
||||
},
|
||||
{
|
||||
component: 'CNavItem',
|
||||
name: 'Tenants',
|
||||
to: '/tenants',
|
||||
icon: 'cil-building',
|
||||
},
|
||||
{
|
||||
component: 'CNavItem',
|
||||
name: 'Widgets',
|
||||
@@ -416,3 +510,12 @@ export default [
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function injectMenusAfterDashboard(dynamicMenus = []) {
|
||||
if (!Array.isArray(baseNav) || baseNav.length === 0) return dynamicMenus
|
||||
|
||||
const [dashboard] = baseNav
|
||||
return [dashboard, ...dynamicMenus]
|
||||
}
|
||||
|
||||
export default baseNav
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import axios from 'axios'
|
||||
import { clearSession, getToken } from '@/utils/session'
|
||||
|
||||
const api = axios.create({
|
||||
// baseURL: 'https://api.radiq.my.id/api',
|
||||
baseURL: 'http://127.0.0.1:8000/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
)
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error?.response?.status === 401 && getToken()) {
|
||||
clearSession()
|
||||
|
||||
if (window.location.hash !== '#/pages/login') {
|
||||
window.location.hash = '#/pages/login'
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -43,7 +43,11 @@ import {
|
||||
cilCode,
|
||||
cilCommentSquare,
|
||||
cilContrast,
|
||||
cilCopy,
|
||||
cilCreditCard,
|
||||
cilCursor,
|
||||
cilDataTransferDown,
|
||||
cilDataTransferUp,
|
||||
cilDrop,
|
||||
cilDollar,
|
||||
cilEnvelopeClosed,
|
||||
@@ -52,6 +56,7 @@ import {
|
||||
cilExternalLink,
|
||||
cilGlobeAlt,
|
||||
cilGrid,
|
||||
cilInfo,
|
||||
cilFile,
|
||||
cilJustifyCenter,
|
||||
cilLaptop,
|
||||
@@ -76,10 +81,19 @@ import {
|
||||
cilStar,
|
||||
cilSun,
|
||||
cilTask,
|
||||
cilTransfer,
|
||||
cilUser,
|
||||
cilUserFemale,
|
||||
cilUserFollow,
|
||||
cilWallet,
|
||||
cilXCircle,
|
||||
cilSquare, // new add
|
||||
cilBarcode,
|
||||
cilBookmark,
|
||||
cilAperture,
|
||||
cilCamera,
|
||||
cilDescription,
|
||||
cilCog,
|
||||
} from '@coreui/icons'
|
||||
|
||||
export const iconsSet = Object.assign(
|
||||
@@ -102,7 +116,11 @@ export const iconsSet = Object.assign(
|
||||
cilCode,
|
||||
cilCommentSquare,
|
||||
cilContrast,
|
||||
cilCopy,
|
||||
cilCreditCard,
|
||||
cilCursor,
|
||||
cilDataTransferDown,
|
||||
cilDataTransferUp,
|
||||
cilDrop,
|
||||
cilDollar,
|
||||
cilEnvelopeClosed,
|
||||
@@ -111,6 +129,7 @@ export const iconsSet = Object.assign(
|
||||
cilExternalLink,
|
||||
cilGlobeAlt,
|
||||
cilGrid,
|
||||
cilInfo,
|
||||
cilFile,
|
||||
cilJustifyCenter,
|
||||
cilLaptop,
|
||||
@@ -135,10 +154,19 @@ export const iconsSet = Object.assign(
|
||||
cilStar,
|
||||
cilSun,
|
||||
cilTask,
|
||||
cilTransfer,
|
||||
cilUser,
|
||||
cilUserFemale,
|
||||
cilUserFollow,
|
||||
cilWallet,
|
||||
cilXCircle,
|
||||
cilSquare, // new add
|
||||
cilBarcode,
|
||||
cilBookmark,
|
||||
cilAperture,
|
||||
cilCamera,
|
||||
cilDescription,
|
||||
cilCog,
|
||||
},
|
||||
{
|
||||
cifUs,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -1,14 +1,12 @@
|
||||
<template>
|
||||
<CFooter class="px-4">
|
||||
<div>
|
||||
<a href="https://coreui.io" target="_blank">CoreUI</a>
|
||||
<span class="ms-1"
|
||||
>© {{ new Date().getFullYear() }} creativeLabs.</span
|
||||
>
|
||||
<a href="https://manjapro.net/" target="_blank">ManjaPro</a>
|
||||
<span class="ms-1">© {{ new Date().getFullYear() }}</span>
|
||||
</div>
|
||||
<div class="ms-auto">
|
||||
<span class="me-1" target="_blank">Powered by</span>
|
||||
<a href="https://coreui.io/vue">CoreUI for Vue</a>
|
||||
<!-- <span class="me-1" target="_blank">Powered by</span> -->
|
||||
<a href="https://radiq.my.id/">PT. Radhika Data Nusantara</a>
|
||||
</div>
|
||||
</CFooter>
|
||||
</template>
|
||||
|
||||
+141
-24
@@ -1,23 +1,73 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { cilList, cilViewModule } from '@coreui/icons'
|
||||
import { useColorModes } from '@coreui/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppBreadcrumb from '@/components/AppBreadcrumb.vue'
|
||||
import AppHeaderDropdownAccnt from '@/components/AppHeaderDropdownAccnt.vue'
|
||||
import { useSidebarStore } from '@/stores/sidebar.js'
|
||||
import {
|
||||
getResponsiveViewMode,
|
||||
RESPONSIVE_VIEW_MODE_EVENT,
|
||||
saveResponsiveViewMode,
|
||||
} from '@/utils/responsiveViewMode.js'
|
||||
import { getMyNotifications, markNotificationRead } from '@/services/notificationService.js'
|
||||
|
||||
const headerClassNames = ref('mb-4 p-0')
|
||||
const headerClassNames = ref('mb-2 p-0')
|
||||
const { colorMode, setColorMode } = useColorModes('coreui-free-vue-admin-template-theme')
|
||||
const sidebar = useSidebarStore()
|
||||
const router = useRouter()
|
||||
const viewMode = ref(getResponsiveViewMode())
|
||||
const appNotifications = ref([])
|
||||
const unreadCount = computed(() => appNotifications.value.filter((item) => !item.read_at).length)
|
||||
|
||||
const handleScroll = () => {
|
||||
headerClassNames.value =
|
||||
document.documentElement.scrollTop > 0 ? 'mb-2 p-0 shadow-sm' : 'mb-2 p-0'
|
||||
}
|
||||
|
||||
const syncViewMode = (event) => {
|
||||
if (event.detail === 'card' || event.detail === 'table') {
|
||||
viewMode.value = event.detail
|
||||
}
|
||||
}
|
||||
|
||||
const toggleViewMode = () => {
|
||||
const nextMode = viewMode.value === 'card' ? 'table' : 'card'
|
||||
viewMode.value = nextMode
|
||||
saveResponsiveViewMode(nextMode)
|
||||
}
|
||||
|
||||
const loadNotifications = async () => {
|
||||
try {
|
||||
const response = await getMyNotifications({ per_page: 8 })
|
||||
appNotifications.value = response?.data?.data || []
|
||||
} catch {
|
||||
appNotifications.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const openNotification = async (item) => {
|
||||
if (!item.read_at) {
|
||||
await markNotificationRead(item.uuid)
|
||||
item.read_at = new Date().toISOString()
|
||||
}
|
||||
if (item.action_url) {
|
||||
if (item.action_url.startsWith('/')) await router.push(item.action_url)
|
||||
else window.location.assign(item.action_url)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('scroll', () => {
|
||||
if (document.documentElement.scrollTop > 0) {
|
||||
headerClassNames.value = 'mb-4 p-0 shadow-sm'
|
||||
} else {
|
||||
headerClassNames.value = 'mb-4 p-0'
|
||||
}
|
||||
})
|
||||
document.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener(RESPONSIVE_VIEW_MODE_EVENT, syncViewMode)
|
||||
loadNotifications()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener(RESPONSIVE_VIEW_MODE_EVENT, syncViewMode)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -29,24 +79,51 @@ onMounted(() => {
|
||||
</CHeaderToggler>
|
||||
<CHeaderNav class="d-none d-md-flex">
|
||||
<CNavItem>
|
||||
<CNavLink href="/dashboard"> Dashboard </CNavLink>
|
||||
</CNavItem>
|
||||
<CNavItem>
|
||||
<CNavLink href="#">Users</CNavLink>
|
||||
</CNavItem>
|
||||
<CNavItem>
|
||||
<CNavLink href="#">Settings</CNavLink>
|
||||
<CNavLink href="#/dashboard"> Dashboard </CNavLink>
|
||||
</CNavItem>
|
||||
</CHeaderNav>
|
||||
<CHeaderNav class="ms-auto">
|
||||
<CNavItem>
|
||||
<CNavLink href="#">
|
||||
<CDropdown variant="nav-item" placement="bottom-end">
|
||||
<CDropdownToggle :caret="false" class="position-relative">
|
||||
<CIcon icon="cil-bell" size="lg" />
|
||||
</CNavLink>
|
||||
</CNavItem>
|
||||
<CBadge
|
||||
v-if="unreadCount"
|
||||
color="danger"
|
||||
shape="rounded-pill"
|
||||
class="header-notification-badge"
|
||||
>{{ unreadCount > 9 ? '9+' : unreadCount }}</CBadge
|
||||
>
|
||||
</CDropdownToggle>
|
||||
<CDropdownMenu class="header-notification-menu">
|
||||
<div class="px-3 py-2 border-bottom">
|
||||
<strong>Notifikasi</strong
|
||||
><small class="d-block text-body-secondary">{{ unreadCount }} belum dibaca</small>
|
||||
</div>
|
||||
<button
|
||||
v-for="item in appNotifications"
|
||||
:key="item.uuid"
|
||||
type="button"
|
||||
class="header-notification-item"
|
||||
:class="{ unread: !item.read_at }"
|
||||
@click="openNotification(item)"
|
||||
>
|
||||
<strong>{{ item.title }}</strong
|
||||
><span>{{ item.body }}</span>
|
||||
</button>
|
||||
<div v-if="!appNotifications.length" class="px-3 py-3 text-body-secondary text-center">
|
||||
Belum ada notifikasi
|
||||
</div>
|
||||
</CDropdownMenu>
|
||||
</CDropdown>
|
||||
<CNavItem>
|
||||
<CNavLink href="#">
|
||||
<CIcon icon="cil-list" size="lg" />
|
||||
<CNavLink
|
||||
component="button"
|
||||
type="button"
|
||||
:aria-label="viewMode === 'card' ? 'Gunakan tampilan tabel' : 'Gunakan tampilan kartu'"
|
||||
:title="viewMode === 'card' ? 'Tampilan tabel' : 'Tampilan kartu'"
|
||||
@click="toggleViewMode"
|
||||
>
|
||||
<CIcon :icon="viewMode === 'card' ? cilList : cilViewModule" size="lg" />
|
||||
</CNavLink>
|
||||
</CNavItem>
|
||||
<CNavItem>
|
||||
@@ -101,8 +178,48 @@ onMounted(() => {
|
||||
<AppHeaderDropdownAccnt />
|
||||
</CHeaderNav>
|
||||
</CContainer>
|
||||
<CContainer class="px-4" fluid>
|
||||
<!-- <CContainer class="px-4" fluid>
|
||||
<AppBreadcrumb />
|
||||
</CContainer>
|
||||
</CContainer> -->
|
||||
</CHeader>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header-notification-badge {
|
||||
position: absolute;
|
||||
top: 0.15rem;
|
||||
right: 0.05rem;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
.header-notification-menu {
|
||||
width: min(22rem, calc(100vw - 1.5rem));
|
||||
max-height: 28rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.header-notification-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding: 0.7rem 1rem;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--cui-border-color);
|
||||
}
|
||||
.header-notification-item:hover {
|
||||
background: var(--cui-tertiary-bg);
|
||||
}
|
||||
.header-notification-item.unread {
|
||||
border-left: 3px solid var(--cui-primary);
|
||||
background: rgba(var(--cui-primary-rgb), 0.08);
|
||||
}
|
||||
.header-notification-item span {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--cui-secondary-color);
|
||||
font-size: 0.78rem;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,83 @@
|
||||
<script setup>
|
||||
import avatar from '@/assets/images/avatars/8.jpg'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getTemporaryFileUrl } from '@/services/fileService'
|
||||
import { getMyProfile } from '@/services/profileService'
|
||||
|
||||
const itemsCount = 42
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const avatarUrl = ref('')
|
||||
const profileLoadedForUserId = ref(null)
|
||||
|
||||
watch(
|
||||
() => [authStore.user?.id, authStore.user?.profile_photo?.uuid],
|
||||
async ([userId]) => {
|
||||
avatarUrl.value = ''
|
||||
if (!userId) return
|
||||
|
||||
try {
|
||||
let user = authStore.user
|
||||
|
||||
if (!user?.profile_photo && profileLoadedForUserId.value !== userId) {
|
||||
profileLoadedForUserId.value = userId
|
||||
const profileResponse = await getMyProfile()
|
||||
user = profileResponse?.data || user
|
||||
authStore.setUser(user)
|
||||
}
|
||||
|
||||
const uuid = user?.profile_photo?.uuid
|
||||
if (!uuid) return
|
||||
|
||||
const response = await getTemporaryFileUrl(uuid)
|
||||
avatarUrl.value = response?.data?.url || ''
|
||||
} catch {
|
||||
avatarUrl.value = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function openProfile() {
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
function openMyApplications() {
|
||||
router.push('/my-applications')
|
||||
}
|
||||
|
||||
function onLogout() {
|
||||
authStore.logout()
|
||||
router.push('/pages/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CDropdown placement="bottom-end" variant="nav-item">
|
||||
<CDropdownToggle class="py-0 pe-0" :caret="false">
|
||||
<CAvatar :src="avatar" size="md" />
|
||||
<CAvatar
|
||||
:src="avatarUrl || undefined"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
size="md"
|
||||
class="header-profile-avatar"
|
||||
>
|
||||
<template v-if="!avatarUrl">
|
||||
{{ authStore.user?.name?.charAt(0)?.toUpperCase() || 'U' }}
|
||||
</template>
|
||||
</CAvatar>
|
||||
</CDropdownToggle>
|
||||
<CDropdownMenu class="pt-0">
|
||||
<CDropdownHeader
|
||||
component="h6"
|
||||
class="bg-body-secondary text-body-secondary fw-semibold mb-2 rounded-top"
|
||||
>
|
||||
Account
|
||||
{{ authStore.user?.name || 'Account' }}
|
||||
</CDropdownHeader>
|
||||
<CDropdownItemText class="small text-body-secondary">
|
||||
{{ authStore.user?.email || '-' }}
|
||||
</CDropdownItemText>
|
||||
<CDropdownItem>
|
||||
<CIcon icon="cil-bell" /> Updates
|
||||
<CBadge color="info" class="ms-auto">{{ itemsCount }}</CBadge>
|
||||
@@ -38,7 +100,10 @@ const itemsCount = 42
|
||||
>
|
||||
Settings
|
||||
</CDropdownHeader>
|
||||
<CDropdownItem> <CIcon icon="cil-user" /> Profile </CDropdownItem>
|
||||
<CDropdownItem @click="openProfile"> <CIcon icon="cil-user" /> Profile </CDropdownItem>
|
||||
<CDropdownItem @click="openMyApplications">
|
||||
<CIcon icon="cil-user-follow" /> Pengajuan Saya
|
||||
</CDropdownItem>
|
||||
<CDropdownItem> <CIcon icon="cil-settings" /> Settings </CDropdownItem>
|
||||
<CDropdownItem>
|
||||
<CIcon icon="cil-dollar" /> Payments
|
||||
@@ -50,7 +115,7 @@ const itemsCount = 42
|
||||
</CDropdownItem>
|
||||
<CDropdownDivider />
|
||||
<CDropdownItem> <CIcon icon="cil-shield-alt" /> Lock Account </CDropdownItem>
|
||||
<CDropdownItem> <CIcon icon="cil-lock-locked" /> Logout </CDropdownItem>
|
||||
<CDropdownItem @click="onLogout"> <CIcon icon="cil-lock-locked" /> Logout </CDropdownItem>
|
||||
</CDropdownMenu>
|
||||
</CDropdown>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { logo } from '@/assets/brand/logo'
|
||||
// import { logo } from '@/assets/brand/logo'
|
||||
import logo from '@/assets/images/Logo_big.png'
|
||||
import { sygnet } from '@/assets/brand/sygnet'
|
||||
import { AppSidebarNav } from '@/components/AppSidebarNav.js'
|
||||
import WalletHeaderWidget from '@/components/WalletHeaderWidget.vue'
|
||||
import { useMenuStore } from '@/stores/menu.js'
|
||||
import { useSidebarStore } from '@/stores/sidebar.js'
|
||||
|
||||
const sidebar = useSidebarStore()
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
function containsWalletMenu(items = []) {
|
||||
return items.some(
|
||||
(item) =>
|
||||
item?.slug === 'wallet' ||
|
||||
item?.url === '/deposit-balance' ||
|
||||
containsWalletMenu(Array.isArray(item?.children) ? item.children : []),
|
||||
)
|
||||
}
|
||||
|
||||
const canViewWallet = computed(
|
||||
() => menuStore.initialized && containsWalletMenu(menuStore.dynamicMenus),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,16 +38,62 @@ const sidebar = useSidebarStore()
|
||||
>
|
||||
<CSidebarHeader class="border-bottom">
|
||||
<RouterLink custom to="/" v-slot="{ href, navigate }">
|
||||
<CSidebarBrand v-bind="$attrs" as="a" :href="href" @click="navigate">
|
||||
<!-- <CSidebarBrand v-bind="$attrs" as="a" :href="href" @click="navigate">
|
||||
<CIcon custom-class-name="sidebar-brand-full" :icon="logo" :height="32" />
|
||||
<CIcon custom-class-name="sidebar-brand-narrow" :icon="sygnet" :height="32" />
|
||||
</CSidebarBrand> -->
|
||||
<CSidebarBrand
|
||||
v-bind="$attrs"
|
||||
as="a"
|
||||
:href="href"
|
||||
@click="navigate"
|
||||
class="d-flex align-items-center text-decoration-none"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<img :src="logo" alt="Logo" style="height: 40px; width: auto" />
|
||||
|
||||
<!-- Teks -->
|
||||
<div class="ms-2 text-start">
|
||||
<div class="fw-bold" style="font-size: 22px; line-height: 1; color: #ff8c00; margin-top: 8px">
|
||||
MANJAPRO
|
||||
</div>
|
||||
<small class="fw-bold" style="font-size: 11px"> PT. Radhika Data Nusantara </small>
|
||||
</div>
|
||||
</CSidebarBrand>
|
||||
</RouterLink>
|
||||
<CCloseButton class="d-lg-none" dark @click="sidebar.toggleVisible()" />
|
||||
</CSidebarHeader>
|
||||
<div v-if="canViewWallet" class="sidebar-wallet-slot">
|
||||
<WalletHeaderWidget />
|
||||
</div>
|
||||
<AppSidebarNav />
|
||||
<CSidebarFooter class="border-top d-none d-lg-flex">
|
||||
<CSidebarToggler @click="sidebar.toggleUnfoldable()" />
|
||||
</CSidebarFooter>
|
||||
</CSidebar>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-wallet-slot {
|
||||
padding: 0.7rem 0.75rem 0.35rem;
|
||||
}
|
||||
|
||||
.sidebar-wallet-slot :deep(.wallet-header) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.sidebar-narrow-unfoldable:not(:hover)) .sidebar-wallet-slot {
|
||||
padding-inline: 0.45rem;
|
||||
}
|
||||
|
||||
:global(.sidebar-narrow-unfoldable:not(:hover)) .sidebar-wallet-slot :deep(.wallet-header) {
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
padding-inline: 0.35rem;
|
||||
}
|
||||
|
||||
:global(.sidebar-narrow-unfoldable:not(:hover)) .sidebar-wallet-slot :deep(.wallet-header span),
|
||||
:global(.sidebar-narrow-unfoldable:not(:hover)) .sidebar-wallet-slot :deep(.wallet-header i) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { defineComponent, h, onMounted, ref, resolveComponent } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
import { cilExternalLink } from '@coreui/icons'
|
||||
import { CBadge, CSidebarNav, CNavItem, CNavGroup, CNavTitle } from '@coreui/vue'
|
||||
import nav from '@/_nav.js'
|
||||
import { injectMenusAfterDashboard } from '@/_nav.js'
|
||||
import { useMenuStore } from '@/stores/menu'
|
||||
|
||||
import simplebar from 'simplebar-vue'
|
||||
import 'simplebar-vue/dist/simplebar.min.css'
|
||||
@@ -24,8 +24,13 @@ const isActiveLink = (route, link) => {
|
||||
|
||||
const currentPath = normalizePath(route.path)
|
||||
const targetPath = normalizePath(link)
|
||||
const activeMenuPath = route.meta?.activeMenu
|
||||
? normalizePath(route.meta.activeMenu)
|
||||
: route.meta?.activeMenuBase && route.params?.scope
|
||||
? normalizePath(`${route.meta.activeMenuBase}/${route.params.scope}`)
|
||||
: null
|
||||
|
||||
return currentPath === targetPath
|
||||
return currentPath === targetPath || activeMenuPath === targetPath
|
||||
}
|
||||
|
||||
const isActiveItem = (route, item) => {
|
||||
@@ -50,6 +55,7 @@ const AppSidebarNav = defineComponent({
|
||||
setup() {
|
||||
const route = useRoute()
|
||||
const firstRender = ref(true)
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
onMounted(() => {
|
||||
firstRender.value = false
|
||||
@@ -96,11 +102,12 @@ const AppSidebarNav = defineComponent({
|
||||
})
|
||||
: h('span', { class: 'nav-icon' }, h('span', { class: 'nav-icon-bullet' })),
|
||||
item.name,
|
||||
item.external && h(resolveComponent('CIcon'), {
|
||||
class: 'ms-2',
|
||||
name: 'cil-external-link',
|
||||
size: 'sm'
|
||||
}),
|
||||
item.external &&
|
||||
h(resolveComponent('CIcon'), {
|
||||
class: 'ms-2',
|
||||
name: 'cil-external-link',
|
||||
size: 'sm',
|
||||
}),
|
||||
item.badge &&
|
||||
h(
|
||||
CBadge,
|
||||
@@ -172,16 +179,19 @@ const AppSidebarNav = defineComponent({
|
||||
)
|
||||
}
|
||||
|
||||
return () =>
|
||||
h(
|
||||
return () => {
|
||||
const mergedNav = injectMenusAfterDashboard(menuStore.mappedMenus)
|
||||
|
||||
return h(
|
||||
CSidebarNav,
|
||||
{
|
||||
as: simplebar,
|
||||
},
|
||||
{
|
||||
default: () => nav.map((item) => renderItem(item)),
|
||||
default: () => mergedNav.map((item) => renderItem(item)),
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import ComponentsImg from '@/assets/images/components.webp'
|
||||
explore extended examples, detailed API documentation, and customization options, refer to
|
||||
our docs.
|
||||
</div>
|
||||
<div class="col-md-auto col-12 mt-3 mt-lg-0">
|
||||
<div class="col-md-auto col-12 mt-3 mt-lg-0 d-flex flex-column">
|
||||
<a
|
||||
class="btn btn-primary text-nowrap text-white"
|
||||
:href="`https://coreui.io/vue/docs/${props.href}`"
|
||||
@@ -37,6 +37,15 @@ import ComponentsImg from '@/assets/images/components.webp'
|
||||
>
|
||||
Explore Documentation
|
||||
</a>
|
||||
<div class="text-center my-1">or</div>
|
||||
<a
|
||||
class="btn btn-danger text-nowrap text-white"
|
||||
href="https://coreui.io/pricing/?framework=vue&src=free-vue-admin-template-docs-banner"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Get CoreUI PRO →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const visible = ref(false)
|
||||
const imageUrl = ref('')
|
||||
const imageAlt = ref('Preview gambar')
|
||||
|
||||
function openPreview(event) {
|
||||
const previewTarget = event.target.closest?.('.image-previewable')
|
||||
if (!previewTarget) return
|
||||
|
||||
const image = previewTarget.matches('img') ? previewTarget : previewTarget.querySelector('img')
|
||||
if (!image?.src) return
|
||||
|
||||
imageUrl.value = image.currentSrc || image.src
|
||||
imageAlt.value = image.alt || previewTarget.dataset.previewAlt || 'Preview gambar'
|
||||
visible.value = true
|
||||
document.body.classList.add('image-preview-open')
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
visible.value = false
|
||||
document.body.classList.remove('image-preview-open')
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'Escape' && visible.value) closePreview()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', openPreview)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', openPreview)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.body.classList.remove('image-preview-open')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="image-preview-fade">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="image-preview-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="imageAlt"
|
||||
@click.self="closePreview"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="image-preview-close"
|
||||
aria-label="Tutup preview gambar"
|
||||
@click="closePreview"
|
||||
>
|
||||
<CIcon icon="cil-x-circle" size="xl" />
|
||||
</button>
|
||||
<img :src="imageUrl" :alt="imageAlt" class="image-preview-modal-image" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getMyWallets } from '@/services/walletService'
|
||||
|
||||
const router = useRouter()
|
||||
const wallets = ref([])
|
||||
const activeIndex = ref(0)
|
||||
let timer
|
||||
const active = computed(() => wallets.value[activeIndex.value] || null)
|
||||
const money = (value) => `Rp ${Number(value || 0).toLocaleString('id-ID')}`
|
||||
|
||||
onMounted(async () => {
|
||||
try { wallets.value = (await getMyWallets())?.data || [] } catch { wallets.value = [] }
|
||||
timer = window.setInterval(() => {
|
||||
if (wallets.value.length > 1) activeIndex.value = (activeIndex.value + 1) % wallets.value.length
|
||||
}, 5000)
|
||||
})
|
||||
onBeforeUnmount(() => window.clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" class="wallet-header" title="Buka saldo" @click="router.push('/deposit-balance')">
|
||||
<CIcon icon="cil-wallet" size="lg" />
|
||||
<span v-if="active"><small>{{ active.label || 'Saldo Deposit' }}</small><strong>{{ money(active.available_balance) }}</strong></span>
|
||||
<span v-else><small>Saldo Deposit</small><strong>Rp 0</strong></span>
|
||||
<i v-if="wallets.length > 1">{{ activeIndex + 1 }}/{{ wallets.length }}</i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wallet-header { display: flex; min-width: 9.5rem; min-height: 3.35rem; align-items: center; gap: .65rem; padding: .5rem .7rem; color: #fff; text-align: left; background: linear-gradient(135deg, #f97316, #ff8c00 58%, #f9b115); border: 1px solid rgba(255, 255, 255, .24); border-radius: .75rem; box-shadow: 0 .35rem .8rem rgba(249, 115, 22, .24); }
|
||||
.wallet-header span { display: flex; min-width: 0; flex: 1; flex-direction: column; line-height: 1.15; }
|
||||
.wallet-header small { color: rgba(255, 255, 255, .72); font-size: .65rem; }
|
||||
.wallet-header strong { overflow: hidden; font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wallet-header i { color: rgba(255, 255, 255, .7); font-size: .6rem; font-style: normal; }
|
||||
.wallet-header:hover { filter: brightness(1.06); }
|
||||
</style>
|
||||
@@ -0,0 +1,707 @@
|
||||
<template>
|
||||
<div class="responsive-data-view brdvx-root" :aria-busy="loading">
|
||||
<div class="data-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<div v-if="!isMobile" class="per-page-wrap">
|
||||
<label class="toolbar-label" for="per-page-select">Show</label>
|
||||
<CFormSelect
|
||||
id="per-page-select"
|
||||
size="sm"
|
||||
class="per-page-select"
|
||||
:model-value="String(perPage)"
|
||||
@change="onPerPageChange"
|
||||
>
|
||||
<option v-for="opt in perPageOptions" :key="opt" :value="String(opt)">{{ opt }}</option>
|
||||
</CFormSelect>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="toolbar-search-wrap">
|
||||
<div class="toolbar-search">
|
||||
<CFormInput
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder="Search"
|
||||
aria-label="Search"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="filterFields.length" class="brdvx-filter-wrap">
|
||||
<CButton
|
||||
style="zoom: 90%"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
variant="outline"
|
||||
class="brdvx-filter-button"
|
||||
:class="{ active: activeFilterCount > 0 }"
|
||||
@click="filterOpen = !filterOpen"
|
||||
>
|
||||
Filter<span v-if="activeFilterCount"> ({{ activeFilterCount }})</span>
|
||||
</CButton>
|
||||
<div v-if="filterOpen" class="brdvx-filter-panel">
|
||||
<div class="brdvx-filter-panel-head">
|
||||
<strong>Filter Data</strong>
|
||||
<button type="button" aria-label="Tutup filter" @click="filterOpen = false">×</button>
|
||||
</div>
|
||||
<div class="brdvx-filter-fields">
|
||||
<div v-for="field in filterFields" :key="field.key">
|
||||
<CFormLabel>{{ field.label }}</CFormLabel>
|
||||
<CFormSelect v-if="field.type === 'select'" v-model="filterValues[field.key]" size="sm">
|
||||
<option value="">{{ field.placeholder || 'Semua' }}</option>
|
||||
<option v-for="option in field.options || []" :key="filterOptionValue(option)" :value="filterOptionValue(option)">
|
||||
{{ filterOptionLabel(option) }}
|
||||
</option>
|
||||
</CFormSelect>
|
||||
<CFormInput v-else v-model="filterValues[field.key]" :type="field.type || 'text'" size="sm" :placeholder="field.placeholder || ''" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="brdvx-filter-actions">
|
||||
<CButton color="secondary" variant="outline" size="sm" @click="resetFilters">Reset</CButton>
|
||||
<CButton color="primary" size="sm" @click="applyFilters">Terapkan</CButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="brdvx-inline-loading" role="status" aria-live="polite">
|
||||
<CSpinner color="primary" size="sm" />
|
||||
<span>Memuat data...</span>
|
||||
</div>
|
||||
|
||||
<template v-if="viewMode === 'card'">
|
||||
<div class="cards-grid" :class="{ mobile: isMobile }">
|
||||
<CCard
|
||||
v-for="(item, rowIndex) in renderedItems"
|
||||
:key="resolveRowKey(item, rowIndex)"
|
||||
class="mobile-card"
|
||||
role="button"
|
||||
@click="onCardClick(item)"
|
||||
>
|
||||
<div v-if="item.image" class="mobile-image-header">
|
||||
<img
|
||||
:src="item.image"
|
||||
:alt="item.name || `Item ${rowIndex + 1}`"
|
||||
class="mobile-image"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="onImageError($event, item)"
|
||||
/>
|
||||
</div>
|
||||
<CCardHeader v-else class="mobile-card-header">
|
||||
<span class="mobile-card-title">{{ item.name || `Row ${rowIndex + 1}` }}</span>
|
||||
<CBadge color="light" class="text-dark">#{{ item.id || rowIndex + 1 }}</CBadge>
|
||||
</CCardHeader>
|
||||
|
||||
<CCardBody class="mobile-card-body">
|
||||
<div class="mobile-fields">
|
||||
<div v-for="col in cardColumns(item)" :key="col.key" class="mobile-field">
|
||||
<span class="field-label">{{ col.label }}</span>
|
||||
<span class="field-value">{{ item[col.key] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideDefaultActions" class="mobile-actions" @click.stop>
|
||||
<CButton
|
||||
v-for="action in mobileActions"
|
||||
:key="action.key"
|
||||
size="sm"
|
||||
:color="action.color"
|
||||
variant="outline"
|
||||
@click="onActionClick(action.key, item)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</CButton>
|
||||
</div>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-list-wrapper">
|
||||
<CCard v-if="!isMobile" class="table-list-card table-list-header-card">
|
||||
<CCardBody class="table-list-card-body">
|
||||
<div class="table-list-main">
|
||||
<div class="table-list-data-col desktop-row-grid" :style="desktopGridStyle">
|
||||
<div
|
||||
v-for="col in desktopHeaderColumns"
|
||||
:key="`head-${col.key}`"
|
||||
class="table-list-cell table-list-head-cell"
|
||||
>
|
||||
<button
|
||||
v-if="serverSide && col.sortable !== false"
|
||||
type="button"
|
||||
class="brdvx-sort-button"
|
||||
@click="toggleSort(col)"
|
||||
>
|
||||
<span>{{ col.label }}</span>
|
||||
<span v-if="sortBy === (col.sortKey || col.key)">
|
||||
{{ sortDirection === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</button>
|
||||
<span v-else class="table-list-value row-pair">
|
||||
<span class="row-right no-label">{{ col.label }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-list-right" :style="desktopActionStyle">
|
||||
<div class="table-list-head-cell action-head">Action</div>
|
||||
</div>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
|
||||
<CCard
|
||||
v-for="(item, rowIndex) in renderedItems"
|
||||
:key="resolveRowKey(item, rowIndex)"
|
||||
class="table-list-card"
|
||||
>
|
||||
<CCardBody class="table-list-card-body">
|
||||
<div class="table-list-main" :class="{ 'with-image': hasImageCell(item) && isMobile }">
|
||||
<div v-if="hasImageCell(item) && isMobile" class="table-list-image-col">
|
||||
<img
|
||||
:src="item.image"
|
||||
:alt="item.name || 'avatar'"
|
||||
class="table-list-mobile-image"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="onImageError($event, item)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="table-list-data-col mobile-split">
|
||||
<div class="mobile-col">
|
||||
<div
|
||||
v-for="col in leftMobileColumns(item)"
|
||||
:key="`${resolveRowKey(item, rowIndex)}-left-${col.key}`"
|
||||
class="table-list-cell"
|
||||
>
|
||||
<span class="table-list-value">{{ item[col.key] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-col">
|
||||
<div
|
||||
v-for="col in rightMobileColumns(item)"
|
||||
:key="`${resolveRowKey(item, rowIndex)}-right-${col.key}`"
|
||||
class="table-list-cell"
|
||||
>
|
||||
<span class="table-list-value">{{ item[col.key] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="table-list-data-col desktop-row-grid" :style="desktopGridStyle">
|
||||
<div
|
||||
v-for="col in displayColumns(item)"
|
||||
:key="`${resolveRowKey(item, rowIndex)}-${col.key}`"
|
||||
class="table-list-cell"
|
||||
>
|
||||
<span class="table-list-value row-pair">
|
||||
<template v-if="col.key === 'image' && item.image">
|
||||
<img
|
||||
:src="item.image"
|
||||
:alt="item.name || 'avatar'"
|
||||
class="table-avatar"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="onImageError($event, item)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="row-right no-label">{{ item[col.key] }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-list-right" :style="desktopActionStyle">
|
||||
<CButton
|
||||
v-if="hiddenTableColumns.length"
|
||||
size="sm"
|
||||
@click="toggleExpandRow(resolveRowKey(item, rowIndex))"
|
||||
>
|
||||
<CIcon :icon="isRowExpanded(resolveRowKey(item, rowIndex)) ? cilCaretTop : cilCaretBottom" />
|
||||
</CButton>
|
||||
|
||||
<div v-if="!hideDefaultActions" class="table-actions">
|
||||
<CButton
|
||||
v-for="action in actions"
|
||||
:key="`${resolveRowKey(item, rowIndex)}-${action.key}`"
|
||||
size="sm"
|
||||
:color="action.color"
|
||||
variant="outline"
|
||||
@click="onActionClick(action.key, item)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</CButton>
|
||||
</div>
|
||||
</div>
|
||||
</CCardBody>
|
||||
|
||||
<div
|
||||
v-if="hiddenTableColumns.length && isRowExpanded(resolveRowKey(item, rowIndex))"
|
||||
class="table-list-hidden"
|
||||
>
|
||||
<div
|
||||
v-for="col in hiddenTableColumns"
|
||||
:key="`${resolveRowKey(item, rowIndex)}-hidden-${col.key}`"
|
||||
class="hidden-item"
|
||||
>
|
||||
<span class="hidden-label">{{ col.label }}</span>
|
||||
<span class="hidden-value">{{ item[col.key] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="totalPages > 1" class="pagination-center">
|
||||
<CPagination align="center" aria-label="Page navigation">
|
||||
<CPaginationItem :disabled="currentPage === 1" @click="goToPage(currentPage - 1)">Previous</CPaginationItem>
|
||||
<CPaginationItem
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
:active="page === currentPage"
|
||||
@click="goToPage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</CPaginationItem>
|
||||
<CPaginationItem :disabled="currentPage === totalPages" @click="goToPage(currentPage + 1)">Next</CPaginationItem>
|
||||
</CPagination>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CIcon } from '@coreui/icons-vue'
|
||||
import { cilCaretBottom, cilCaretTop } from '@coreui/icons'
|
||||
import {
|
||||
getResponsiveViewMode,
|
||||
RESPONSIVE_VIEW_MODE_EVENT,
|
||||
} from '@/utils/responsiveViewMode.js'
|
||||
|
||||
const props = defineProps({
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
rowKey: {
|
||||
type: String,
|
||||
default: 'id',
|
||||
},
|
||||
mobileBreakpoint: {
|
||||
type: Number,
|
||||
default: 768,
|
||||
},
|
||||
mobileVisibleCount: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
desktopPrimaryCount: {
|
||||
type: Number,
|
||||
default: 6,
|
||||
},
|
||||
actions: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ key: 'detail', label: 'Detail', color: 'primary' },
|
||||
{ key: 'edit', label: 'Edit', color: 'success' },
|
||||
{ key: 'delete', label: 'Delete', color: 'danger' },
|
||||
],
|
||||
},
|
||||
hideDefaultActions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rowRoute: {
|
||||
type: [String, Function],
|
||||
default: null,
|
||||
},
|
||||
serverSide: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
pagination: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
initialSortBy: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
initialSortDirection: {
|
||||
type: String,
|
||||
default: 'asc',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterFields: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
initialFilters: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['action-click', 'row-click', 'query-change'])
|
||||
|
||||
const router = useRouter()
|
||||
const width = ref(window.innerWidth)
|
||||
const isMobile = computed(() => width.value < props.mobileBreakpoint)
|
||||
|
||||
const viewMode = ref(getResponsiveViewMode())
|
||||
const searchQuery = ref('')
|
||||
const filterOpen = ref(false)
|
||||
const filterValues = reactive({ ...props.initialFilters })
|
||||
const activeFilterCount = computed(() =>
|
||||
Object.values(filterValues).filter((value) => value !== '' && value !== null && value !== undefined).length,
|
||||
)
|
||||
const perPageOptions = [10, 20, 50, 100]
|
||||
const perPage = ref(10)
|
||||
const currentPage = ref(1)
|
||||
const mobileLoadedPages = ref(1)
|
||||
const expandedRows = ref(new Set())
|
||||
const sortBy = ref(props.initialSortBy)
|
||||
const sortDirection = ref(props.initialSortDirection === 'desc' ? 'desc' : 'asc')
|
||||
let searchTimer = null
|
||||
|
||||
const normalizeValue = (val) => (val === null || val === undefined ? '' : String(val).toLowerCase())
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
if (!query) return props.items
|
||||
|
||||
return props.items.filter((item) =>
|
||||
props.columns.some((col) => normalizeValue(item?.[col.key]).includes(query)),
|
||||
)
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (props.serverSide) return Math.max(1, Number(props.pagination?.last_page) || 1)
|
||||
if (isMobile.value) return 1
|
||||
return Math.max(1, Math.ceil(filteredItems.value.length / perPage.value))
|
||||
})
|
||||
|
||||
const desktopItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * perPage.value
|
||||
return filteredItems.value.slice(start, start + perPage.value)
|
||||
})
|
||||
|
||||
const mobileItems = computed(() => {
|
||||
const size = perPage.value * mobileLoadedPages.value
|
||||
return filteredItems.value.slice(0, size)
|
||||
})
|
||||
|
||||
const renderedItems = computed(() => {
|
||||
if (props.serverSide) return props.items
|
||||
return isMobile.value ? mobileItems.value : desktopItems.value
|
||||
})
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const lastPage = totalPages.value
|
||||
if (lastPage <= 7) return Array.from({ length: lastPage }, (_, index) => index + 1)
|
||||
|
||||
const start = Math.max(1, Math.min(currentPage.value - 3, lastPage - 6))
|
||||
return Array.from({ length: 7 }, (_, index) => start + index)
|
||||
})
|
||||
|
||||
const desktopVisibleColumns = computed(() => Math.min(props.desktopPrimaryCount, props.columns.length))
|
||||
const mobileTableVisibleCount = 6
|
||||
|
||||
const visibleTableColumns = computed(() => {
|
||||
if (isMobile.value && viewMode.value === 'table') {
|
||||
return props.columns.slice(0, mobileTableVisibleCount)
|
||||
}
|
||||
return props.columns.slice(0, desktopVisibleColumns.value)
|
||||
})
|
||||
|
||||
const hiddenTableColumns = computed(() => {
|
||||
if (viewMode.value === 'table' && props.columns.length > mobileTableVisibleCount) {
|
||||
return props.columns.slice(mobileTableVisibleCount)
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const hasImageCell = (item) => Boolean(item?.image)
|
||||
|
||||
const onImageError = (event, item) => {
|
||||
const fallback = item?.imageFallback
|
||||
if (!fallback || event.target.src.endsWith(fallback)) return
|
||||
event.target.src = fallback
|
||||
}
|
||||
|
||||
const displayColumns = (item) => {
|
||||
const cols = visibleTableColumns.value
|
||||
if (isMobile.value && hasImageCell(item)) {
|
||||
return cols.filter((col) => col.key !== 'image')
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
const mobileColumns = computed(() => props.columns.slice(0, props.mobileVisibleCount))
|
||||
|
||||
const cardColumns = (item) =>
|
||||
mobileColumns.value.filter((column) => {
|
||||
if (column.key === 'id') return false
|
||||
if (item?.image && (column.key === 'image' || column.key === 'avatar')) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const leftMobileColumns = (item) => {
|
||||
const cols = displayColumns(item)
|
||||
const split = Math.ceil(cols.length / 2)
|
||||
return cols.slice(0, split)
|
||||
}
|
||||
|
||||
const rightMobileColumns = (item) => {
|
||||
const cols = displayColumns(item)
|
||||
const split = Math.ceil(cols.length / 2)
|
||||
return cols.slice(split)
|
||||
}
|
||||
|
||||
const desktopHeaderColumns = computed(() => visibleTableColumns.value)
|
||||
const mobileActions = computed(() => props.actions)
|
||||
|
||||
const desktopColumnCount = computed(() => Math.max(1, desktopHeaderColumns.value.length))
|
||||
const actionColumnWidth = computed(() => {
|
||||
const actionCount = props.actions?.length ?? 0
|
||||
if (actionCount <= 1) return '140px'
|
||||
if (actionCount === 2) return '180px'
|
||||
if (actionCount === 3) return '230px'
|
||||
return '280px'
|
||||
})
|
||||
|
||||
const desktopGridStyle = computed(() => ({
|
||||
'--brdvx-desktop-cols': desktopColumnCount.value,
|
||||
}))
|
||||
|
||||
const desktopActionStyle = computed(() => ({
|
||||
'--brdvx-action-col-width': actionColumnWidth.value,
|
||||
}))
|
||||
|
||||
const resolveRowKey = (item, index) => item?.[props.rowKey] ?? `row-${index}`
|
||||
|
||||
const onCardClick = (item) => {
|
||||
emit('row-click', item)
|
||||
|
||||
if (typeof props.rowRoute === 'function') {
|
||||
const target = props.rowRoute(item)
|
||||
if (target) router.push(target)
|
||||
} else if (typeof props.rowRoute === 'string' && props.rowRoute) {
|
||||
router.push(props.rowRoute.replace(':id', item?.id))
|
||||
}
|
||||
}
|
||||
|
||||
const onActionClick = (actionKey, item) => {
|
||||
emit('action-click', { action: actionKey, item })
|
||||
}
|
||||
|
||||
const syncViewMode = (event) => {
|
||||
if (event.detail === 'table' || event.detail === 'card') {
|
||||
viewMode.value = event.detail
|
||||
}
|
||||
}
|
||||
|
||||
const filterOptionValue = (option) => Array.isArray(option) ? option[0] : option.value
|
||||
const filterOptionLabel = (option) => Array.isArray(option) ? option[1] : option.label
|
||||
const applyFilters = () => {
|
||||
currentPage.value = 1
|
||||
filterOpen.value = false
|
||||
emitQueryChange()
|
||||
}
|
||||
const resetFilters = () => {
|
||||
Object.keys(filterValues).forEach((key) => { filterValues[key] = '' })
|
||||
props.filterFields.forEach((field) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(filterValues, field.key)) filterValues[field.key] = ''
|
||||
})
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
const onPerPageChange = (event) => {
|
||||
const value = Number(event.target.value)
|
||||
if (!Number.isNaN(value)) {
|
||||
perPage.value = value
|
||||
currentPage.value = 1
|
||||
mobileLoadedPages.value = 1
|
||||
emitQueryChange()
|
||||
}
|
||||
}
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page < 1 || page > totalPages.value) return
|
||||
currentPage.value = page
|
||||
if (props.serverSide) emitQueryChange()
|
||||
}
|
||||
|
||||
const toggleSort = (column) => {
|
||||
const key = column.sortKey || column.key
|
||||
if (sortBy.value === key) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortBy.value = key
|
||||
sortDirection.value = 'asc'
|
||||
}
|
||||
currentPage.value = 1
|
||||
emitQueryChange()
|
||||
}
|
||||
|
||||
const emitQueryChange = () => {
|
||||
if (!props.serverSide) return
|
||||
|
||||
emit('query-change', {
|
||||
page: currentPage.value,
|
||||
per_page: perPage.value,
|
||||
search: searchQuery.value.trim() || undefined,
|
||||
sort_by: sortBy.value || undefined,
|
||||
sort_direction: sortBy.value ? sortDirection.value : undefined,
|
||||
...Object.fromEntries(
|
||||
Object.entries(filterValues).map(([key, value]) => [key, value === '' ? undefined : value]),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const isRowExpanded = (rowKey) => expandedRows.value.has(rowKey)
|
||||
|
||||
const toggleExpandRow = (rowKey) => {
|
||||
const clone = new Set(expandedRows.value)
|
||||
if (clone.has(rowKey)) clone.delete(rowKey)
|
||||
else clone.add(rowKey)
|
||||
expandedRows.value = clone
|
||||
}
|
||||
|
||||
const handleInfiniteScroll = () => {
|
||||
if (!isMobile.value || props.serverSide) return
|
||||
const nearBottom =
|
||||
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 120
|
||||
|
||||
if (!nearBottom) return
|
||||
|
||||
const maxPage = Math.max(1, Math.ceil(filteredItems.value.length / perPage.value))
|
||||
if (mobileLoadedPages.value < maxPage) {
|
||||
mobileLoadedPages.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
const onResize = () => {
|
||||
width.value = window.innerWidth
|
||||
}
|
||||
|
||||
watch([searchQuery, () => props.items], () => {
|
||||
if (!props.serverSide) currentPage.value = 1
|
||||
mobileLoadedPages.value = 1
|
||||
expandedRows.value = new Set()
|
||||
})
|
||||
|
||||
watch(searchQuery, () => {
|
||||
if (!props.serverSide) return
|
||||
|
||||
window.clearTimeout(searchTimer)
|
||||
searchTimer = window.setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
emitQueryChange()
|
||||
}, 400)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.pagination,
|
||||
(pagination) => {
|
||||
if (!props.serverSide) return
|
||||
currentPage.value = Number(pagination?.current_page) || 1
|
||||
perPage.value = Number(pagination?.per_page) || perPage.value
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => isMobile.value,
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
mobileLoadedPages.value = 1
|
||||
expandedRows.value = new Set()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize)
|
||||
window.addEventListener('scroll', handleInfiniteScroll, { passive: true })
|
||||
window.addEventListener(RESPONSIVE_VIEW_MODE_EVENT, syncViewMode)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(searchTimer)
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('scroll', handleInfiniteScroll)
|
||||
window.removeEventListener(RESPONSIVE_VIEW_MODE_EVENT, syncViewMode)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.brdvx-filter-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.brdvx-filter-button.active {
|
||||
background-color: var(--cui-light-bg);
|
||||
color: var(--cui-secondary-color);
|
||||
border-color: var(--cui-secondary-color);
|
||||
}
|
||||
|
||||
.brdvx-filter-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.45rem);
|
||||
right: 0;
|
||||
z-index: 1050;
|
||||
width: min(22rem, calc(100vw - 2rem));
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--cui-border-color);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--cui-body-bg);
|
||||
box-shadow: 0 0.75rem 2rem rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.brdvx-filter-panel-head,
|
||||
.brdvx-filter-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brdvx-filter-panel-head button {
|
||||
padding: 0;
|
||||
color: var(--cui-secondary-color);
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.brdvx-filter-fields {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
margin: 0.8rem 0;
|
||||
}
|
||||
|
||||
.brdvx-filter-fields :deep(.form-label) {
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.brdvx-filter-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
|
||||
const props = defineProps({
|
||||
latitude: { type: [String, Number], default: '' },
|
||||
longitude: { type: [String, Number], default: '' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:latitude', 'update:longitude'])
|
||||
|
||||
const mapElement = ref(null)
|
||||
let map = null
|
||||
let marker = null
|
||||
let resizeObserver = null
|
||||
|
||||
const hasCoordinates = () =>
|
||||
Number.isFinite(Number(props.latitude)) &&
|
||||
Number.isFinite(Number(props.longitude)) &&
|
||||
props.latitude !== '' &&
|
||||
props.longitude !== ''
|
||||
|
||||
function coordinates() {
|
||||
return hasCoordinates()
|
||||
? [Number(props.longitude), Number(props.latitude)]
|
||||
: [118, -2.5]
|
||||
}
|
||||
|
||||
function emitCoordinates(lngLat) {
|
||||
emit('update:latitude', Number(lngLat.lat).toFixed(7))
|
||||
emit('update:longitude', Number(lngLat.lng).toFixed(7))
|
||||
}
|
||||
|
||||
function placeMarker(lngLat, center = false) {
|
||||
if (!map) return
|
||||
if (!marker) {
|
||||
marker = new maplibregl.Marker({ color: '#2f74c0', draggable: !props.readonly })
|
||||
.setLngLat(lngLat)
|
||||
.addTo(map)
|
||||
if (!props.readonly) marker.on('dragend', () => emitCoordinates(marker.getLngLat()))
|
||||
} else {
|
||||
marker.setLngLat(lngLat)
|
||||
}
|
||||
if (center) map.easeTo({ center: lngLat, zoom: Math.max(map.getZoom(), 15) })
|
||||
}
|
||||
|
||||
async function initializeMap() {
|
||||
await nextTick()
|
||||
if (!mapElement.value || map) return
|
||||
|
||||
map = new maplibregl.Map({
|
||||
container: mapElement.value,
|
||||
center: coordinates(),
|
||||
zoom: hasCoordinates() ? 16 : 4,
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
satellite: {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
],
|
||||
tileSize: 256,
|
||||
maxzoom: 19,
|
||||
attribution: 'Tiles © Esri and imagery providers',
|
||||
},
|
||||
transportation: {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}',
|
||||
],
|
||||
tileSize: 256,
|
||||
maxzoom: 19,
|
||||
},
|
||||
labels: {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
|
||||
],
|
||||
tileSize: 256,
|
||||
maxzoom: 19,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{ id: 'satellite', type: 'raster', source: 'satellite' },
|
||||
{ id: 'transportation', type: 'raster', source: 'transportation' },
|
||||
{ id: 'labels', type: 'raster', source: 'labels' },
|
||||
],
|
||||
},
|
||||
})
|
||||
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right')
|
||||
if (!props.readonly) {
|
||||
map.on('click', (event) => {
|
||||
placeMarker(event.lngLat)
|
||||
emitCoordinates(event.lngLat)
|
||||
})
|
||||
}
|
||||
if (hasCoordinates()) placeMarker(coordinates())
|
||||
|
||||
resizeObserver = new ResizeObserver(() => map?.resize())
|
||||
resizeObserver.observe(mapElement.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.latitude, props.longitude],
|
||||
() => {
|
||||
if (hasCoordinates()) placeMarker(coordinates(), true)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(initializeMap)
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
marker?.remove()
|
||||
map?.remove()
|
||||
marker = null
|
||||
map = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="customer-location-map">
|
||||
<div ref="mapElement" class="map-canvas"></div>
|
||||
<div v-if="!readonly" class="map-help">Klik peta atau geser marker untuk menentukan lokasi pemasangan.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customer-location-map {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.map-canvas {
|
||||
width: 100%;
|
||||
min-height: 20rem;
|
||||
}
|
||||
|
||||
.map-help {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
left: 0.75rem;
|
||||
z-index: 2;
|
||||
max-width: max-content;
|
||||
padding: 0.4rem 0.65rem;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 0.45rem;
|
||||
background: rgba(20, 30, 45, 0.78);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.map-canvas {
|
||||
min-height: 17rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<CModal :visible="visible" alignment="center" @close="handleClose" size="lg">
|
||||
<CModalHeader dismiss>
|
||||
<CModalTitle>Assign Material</CModalTitle>
|
||||
</CModalHeader>
|
||||
|
||||
<CModalBody>
|
||||
<CAlert v-if="validationError" color="danger" class="py-2 mb-3">
|
||||
{{ validationError }}
|
||||
</CAlert>
|
||||
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Scan QR Code / Barcode</strong>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Barcode</CFormLabel>
|
||||
<CFormInput v-model="form.barcode_id" placeholder="Scan atau ketik barcode" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3 d-flex gap-2">
|
||||
<CButton color="primary" @click="startScanner" :disabled="scannerRunning">
|
||||
Buka Kamera
|
||||
</CButton>
|
||||
<CButton color="secondary" @click="stopScanner" :disabled="!scannerRunning">
|
||||
Tutup Kamera
|
||||
</CButton>
|
||||
</div>
|
||||
|
||||
<div v-if="scannerRunning" id="scanner-container" class="scanner-preview mb-3"></div>
|
||||
|
||||
<div class="text-body-secondary small">Status : {{ scannerStatus }}</div>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
|
||||
<div class="mb-3">
|
||||
<CFormLabel>User ID</CFormLabel>
|
||||
<CFormInput v-model="form.user_id" placeholder="Masukan User ID" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Nama Material</CFormLabel>
|
||||
<CFormInput v-model="form.material_name" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Type</CFormLabel>
|
||||
<CFormSelect v-model="form.type">
|
||||
<option value="serialized">Serialized</option>
|
||||
<option value="consumable">Consumable</option>
|
||||
</CFormSelect>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" v-if="form.type === 'serialized'">
|
||||
<CFormLabel>Serial Number</CFormLabel>
|
||||
<CFormInput v-model="form.serial_number" />
|
||||
</div>
|
||||
|
||||
<CRow>
|
||||
<CCol>
|
||||
<CFormLabel>Qty</CFormLabel>
|
||||
<CFormInput type="number" v-model="form.qty" />
|
||||
</CCol>
|
||||
<CCol>
|
||||
<CFormLabel>Unit</CFormLabel>
|
||||
<CFormInput v-model="form.unit" />
|
||||
</CCol>
|
||||
</CRow>
|
||||
</CModalBody>
|
||||
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="handleClose" :disabled="saving">Batal</CButton>
|
||||
<CButton color="primary" @click="handleSave" :disabled="saving">
|
||||
<span v-if="saving">Menyimpan...</span>
|
||||
<span v-else>Simpan</span>
|
||||
</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import Swal from 'sweetalert2'
|
||||
import { showError } from '@/utils/swal'
|
||||
|
||||
const props = defineProps({ visible: Boolean, form: Object, saving: Boolean })
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
const scannerStatus = ref('Siap Scan')
|
||||
const scannerRunning = ref(false)
|
||||
const html5QrCode = ref(null)
|
||||
|
||||
let scriptLoaded = false
|
||||
|
||||
function loadScannerLibrary() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.Html5Qrcode) {
|
||||
scriptLoaded = true
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
if (scriptLoaded) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://unpkg.com/html5-qrcode'
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
scriptLoaded = true
|
||||
resolve()
|
||||
}
|
||||
script.onerror = () => reject(new Error('Gagal memuat library scanner'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
async function startScanner() {
|
||||
try {
|
||||
await loadScannerLibrary()
|
||||
scannerStatus.value = 'Meminta akses kamera...'
|
||||
scannerRunning.value = true
|
||||
|
||||
await nextTick()
|
||||
|
||||
html5QrCode.value = new Html5Qrcode('scanner-container')
|
||||
|
||||
await html5QrCode.value.start(
|
||||
{ facingMode: 'environment' },
|
||||
{ fps: 10, qrbox: { width: 250, height: 250 } },
|
||||
onScanSuccess,
|
||||
)
|
||||
scannerStatus.value = 'Kamera menyala, arahkan ke QR Code'
|
||||
} catch (err) {
|
||||
scannerRunning.value = false
|
||||
const msg = err.toString()
|
||||
if (msg.includes('NotAllowedError')) {
|
||||
scannerStatus.value = 'Izin kamera ditolak'
|
||||
} else if (msg.includes('NotFoundError')) {
|
||||
scannerStatus.value = 'Kamera tidak ditemukan'
|
||||
} else {
|
||||
scannerStatus.value = 'Gagal membuka kamera'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onScanSuccess(decodedText) {
|
||||
props.form.barcode_id = decodedText
|
||||
scannerStatus.value = 'QR Code berhasil dipindai.'
|
||||
validationError.value = ''
|
||||
stopScanner()
|
||||
}
|
||||
|
||||
async function stopScanner() {
|
||||
try {
|
||||
if (html5QrCode.value) {
|
||||
await html5QrCode.value.stop()
|
||||
await html5QrCode.value.clear()
|
||||
}
|
||||
} catch {
|
||||
// Abaikan error saat menghentikan scanner
|
||||
} finally {
|
||||
html5QrCode.value = null
|
||||
scannerRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const validationError = ref('')
|
||||
|
||||
const defaultForm = {
|
||||
user_id: null,
|
||||
barcode_id: '',
|
||||
material_name: '',
|
||||
type: 'serialized',
|
||||
serial_number: '',
|
||||
qty: 1,
|
||||
unit: '',
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(props.form, defaultForm)
|
||||
scannerStatus.value = 'Siap Scan'
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!props.form.barcode_id) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Gagal',
|
||||
text: 'Barcode harus diisi',
|
||||
confirmButtonText: 'Tutup',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!props.form.user_id) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: 'Gagal',
|
||||
text: 'User ID harus diisi',
|
||||
confirmButtonText: 'Tutup',
|
||||
})
|
||||
return
|
||||
}
|
||||
emit('save')
|
||||
}
|
||||
|
||||
async function resetState() {
|
||||
resetForm()
|
||||
await stopScanner()
|
||||
}
|
||||
|
||||
async function handleClose() {
|
||||
await resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (!val) {
|
||||
resetForm()
|
||||
stopScanner()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadScannerLibrary()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopScanner()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scanner-preview {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.scanner-preview :deep(video) {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<CModal :visible="visible" alignment="center" size="lg" @close="$emit('close')">
|
||||
<CModalHeader dismiss>
|
||||
<CModalTitle>Detail Material</CModalTitle>
|
||||
</CModalHeader>
|
||||
|
||||
<CModalBody>
|
||||
<CTable>
|
||||
<CTableBody>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell style="width: 35%">Nama Material</CTableHeaderCell>
|
||||
<CTableDataCell>{{ item.material_name }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Type</CTableHeaderCell>
|
||||
<CTableDataCell>
|
||||
<CBadge :color="item.type === 'serialized' ? 'success' : 'warning'">
|
||||
{{ item.type }}
|
||||
</CBadge>
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
|
||||
<!-- serialized: barcode & serial number -->
|
||||
<template v-if="item.type === 'serialized'">
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Barcode</CTableHeaderCell>
|
||||
<CTableDataCell>{{ item.barcode_id }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Serial Number</CTableHeaderCell>
|
||||
<CTableDataCell>{{ item.serial_number || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Quantity</CTableHeaderCell>
|
||||
<CTableDataCell>1</CTableDataCell>
|
||||
</CTableRow>
|
||||
</template>
|
||||
|
||||
<!-- consumable: received, used, remaining -->
|
||||
<template v-if="item.type === 'consumable'">
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Received</CTableHeaderCell>
|
||||
<CTableDataCell>{{ item.qty?.received || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Used</CTableHeaderCell>
|
||||
<CTableDataCell>{{ item.qty?.used || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Remaining</CTableHeaderCell>
|
||||
<CTableDataCell>{{ item.qty?.remaining || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
</template>
|
||||
|
||||
<CTableRow v-if="item.type === 'consumable'">
|
||||
<CTableHeaderCell>Unit</CTableHeaderCell>
|
||||
<CTableDataCell>{{ item.qty?.unit || item.unit || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Status</CTableHeaderCell>
|
||||
<CTableDataCell>
|
||||
<CBadge
|
||||
:color="
|
||||
item.status === 'Assigned'
|
||||
? 'success'
|
||||
: item.status === 'Returned'
|
||||
? 'secondary'
|
||||
: 'info'
|
||||
"
|
||||
>
|
||||
{{ item.status }}
|
||||
</CBadge>
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
</CTableBody>
|
||||
</CTable>
|
||||
</CModalBody>
|
||||
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="$emit('close')">Tutup</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
visible: Boolean,
|
||||
item: Object,
|
||||
})
|
||||
defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.table) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
:deep(.table td),
|
||||
:deep(.table th) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div v-if="histories.length" class="timeline">
|
||||
<div v-for="(item, index) in histories" :key="index" class="timeline-item">
|
||||
<div class="timeline-icon">
|
||||
<CIcon :icon="getHistoryIcon(item.action)" />
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<CBadge :color="getHistoryColor(item.action)">
|
||||
{{ item.action }}
|
||||
</CBadge>
|
||||
<small class="text-body-secondary">{{ item.created_at }}</small>
|
||||
</div>
|
||||
<div class="mt-2">{{ item.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-5">
|
||||
<h6>Belum Ada Riwayat Material</h6>
|
||||
<p class="text-body-secondary">History muncul setelah assign/transfer/return.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { cilUserPlus, cilSwapHorizontal, cilArrowCircleLeft } from '@coreui/icons'
|
||||
|
||||
defineProps({ histories: Array })
|
||||
|
||||
function getHistoryColor(action) {
|
||||
switch (action?.toLowerCase()) {
|
||||
case 'assigned':
|
||||
return 'success'
|
||||
case 'transfer':
|
||||
return 'info'
|
||||
case 'returned':
|
||||
return 'secondary'
|
||||
default:
|
||||
return 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
function getHistoryIcon(action) {
|
||||
switch (action?.toLowerCase()) {
|
||||
case 'assigned':
|
||||
return cilUserPlus
|
||||
case 'transfer':
|
||||
return cilSwapHorizontal
|
||||
case 'returned':
|
||||
return cilArrowCircleLeft
|
||||
default:
|
||||
return cilUserPlus
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.timeline {
|
||||
border-left: 2px solid #e9ecef;
|
||||
margin-left: 10px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.timeline-icon {
|
||||
position: absolute;
|
||||
left: -32px;
|
||||
top: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 2px solid #adb5bd;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6c757d;
|
||||
}
|
||||
.timeline-content {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div v-if="loading" class="text-center py-5">
|
||||
<CSpinner color="primary" class="mb-3" />
|
||||
<p class="text-body-secondary">Memuat data material...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="data.length === 0" class="text-center py-5">
|
||||
<h6 class="text-body-secondary">Tidak ada data material</h6>
|
||||
<p class="text-body-secondary small">Belum ada material yang terdaftar.</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<CTable>
|
||||
<CTableHead>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell scope="col" style="width: 50px">No</CTableHeaderCell>
|
||||
<CTableHeaderCell>Barcode</CTableHeaderCell>
|
||||
<CTableHeaderCell>Material</CTableHeaderCell>
|
||||
<CTableHeaderCell>Type</CTableHeaderCell>
|
||||
<CTableHeaderCell class="text-center">Aksi</CTableHeaderCell>
|
||||
</CTableRow>
|
||||
</CTableHead>
|
||||
|
||||
<CTableBody>
|
||||
<CTableRow v-for="(item, index) in data" :key="item.barcode_id">
|
||||
<CTableHeaderCell scope="row">{{
|
||||
(currentPage - 1) * perPage + index + 1
|
||||
}}</CTableHeaderCell>
|
||||
<CTableDataCell>{{ item.barcode_id }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.material_name }}</CTableDataCell>
|
||||
<CTableDataCell>
|
||||
<CBadge :color="item.type === 'serialized' ? 'success' : 'primary'">
|
||||
{{ item.type }}
|
||||
</CBadge>
|
||||
</CTableDataCell>
|
||||
<CTableDataCell class="text-center">
|
||||
<CButton size="sm" color="info" class="me-1" @click="$emit('detail', item)"
|
||||
>Detail</CButton
|
||||
>
|
||||
<CButton size="sm" color="warning" class="me-1" @click="$emit('transfer', item)"
|
||||
>Transfer</CButton
|
||||
>
|
||||
<CButton size="sm" color="danger" @click="$emit('return', item)">Return</CButton>
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
</CTableBody>
|
||||
</CTable>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span>Tampil</span>
|
||||
<CFormSelect
|
||||
style="width: auto"
|
||||
:value="perPage"
|
||||
@change="$emit('update:perPage', Number($event.target.value))"
|
||||
>
|
||||
<option :value="15">15</option>
|
||||
<option :value="25">25</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</CFormSelect>
|
||||
<span> dari {{ totalItems }} data </span>
|
||||
</div>
|
||||
<CPagination class="mb-0" v-if="totalPages > 1">
|
||||
<CPaginationItem :disabled="currentPage == 1" @click="$emit('page', currentPage - 1)">
|
||||
Previous
|
||||
</CPaginationItem>
|
||||
|
||||
<CPaginationItem
|
||||
v-for="page in totalPages"
|
||||
:key="page"
|
||||
:active="page == currentPage"
|
||||
@click="$emit('page', page)"
|
||||
>
|
||||
{{ page }}
|
||||
</CPaginationItem>
|
||||
|
||||
<CPaginationItem
|
||||
:disabled="currentPage == totalPages"
|
||||
@click="$emit('page', currentPage + 1)"
|
||||
>
|
||||
Next
|
||||
</CPaginationItem>
|
||||
</CPagination>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
data: Array,
|
||||
totalPages: Number,
|
||||
currentPage: Number,
|
||||
perPage: Number,
|
||||
totalItems: Number,
|
||||
loading: Boolean,
|
||||
})
|
||||
defineEmits(['detail', 'transfer', 'return', 'page', 'update:perPage'])
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<CModal :visible="visible" alignment="center" @close="$emit('close')">
|
||||
<CModalHeader dismiss>
|
||||
<CModalTitle>Return Material</CModalTitle>
|
||||
</CModalHeader>
|
||||
|
||||
<CModalBody>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Barcode</CFormLabel>
|
||||
<CFormInput v-model="form.barcode_id" readonly />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<CFormLabel>User ID</CFormLabel>
|
||||
<CFormInput v-model="form.user_id" readonly />
|
||||
</div>
|
||||
</CModalBody>
|
||||
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="$emit('close')" :disabled="saving">Batal</CButton>
|
||||
<CButton color="danger" @click="$emit('save')" :disabled="saving">
|
||||
<span v-if="saving">Memproses...</span>
|
||||
<span v-else>Return</span>
|
||||
</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
visible: Boolean,
|
||||
form: Object,
|
||||
saving: Boolean,
|
||||
})
|
||||
defineEmits(['close', 'save'])
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<CModal :visible="visible" alignment="center" @close="$emit('close')">
|
||||
<CModalHeader dismiss>
|
||||
<CModalTitle>Transfer Material</CModalTitle>
|
||||
</CModalHeader>
|
||||
|
||||
<CModalBody>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Barcode</CFormLabel>
|
||||
<CFormInput v-model="form.barcode_id" readonly />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<CFormLabel>User ID Asal</CFormLabel>
|
||||
<CFormInput v-model="form.from_user_id" readonly />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<CFormLabel>User ID Tujuan</CFormLabel>
|
||||
<CFormInput v-model="form.to_user_id" placeholder="Masukkan User ID Tujuan" />
|
||||
</div>
|
||||
</CModalBody>
|
||||
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="$emit('close')" :disabled="saving">Batal</CButton>
|
||||
<CButton color="warning" @click="$emit('save')" :disabled="saving">
|
||||
<span v-if="saving">Transfer...</span>
|
||||
<span v-else>Transfer</span>
|
||||
</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
visible: Boolean,
|
||||
form: Object,
|
||||
saving: Boolean,
|
||||
})
|
||||
defineEmits(['close', 'save'])
|
||||
</script>
|
||||
@@ -11,7 +11,7 @@ import AppSidebar from '@/components/AppSidebar.vue'
|
||||
<div class="wrapper d-flex flex-column min-vh-100">
|
||||
<AppHeader />
|
||||
<div class="body flex-grow-1">
|
||||
<CContainer class="px-4" lg>
|
||||
<CContainer class="px-1" fluid>
|
||||
<router-view />
|
||||
</CContainer>
|
||||
</div>
|
||||
|
||||
+35
-3
@@ -1,24 +1,56 @@
|
||||
/**
|
||||
* main.js - Application Entry Point
|
||||
*
|
||||
* This file initializes the Vue 3 application and configures:
|
||||
* - Pinia for state management
|
||||
* - Vue Router for client-side routing
|
||||
* - CoreUI Vue component library
|
||||
* - Global icon system
|
||||
* - Documentation helper components
|
||||
*
|
||||
* The application uses:
|
||||
* - Vue 3 Composition API
|
||||
* - Vite for building and development
|
||||
* - CoreUI Vue components
|
||||
* - Hash-based routing for static hosting compatibility
|
||||
*/
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// CoreUI Vue components and icons
|
||||
import CoreuiVue from '@coreui/vue'
|
||||
import CIcon from '@coreui/icons-vue'
|
||||
import { iconsSet as icons } from '@/assets/icons'
|
||||
|
||||
// Documentation components (remove in production if not needed)
|
||||
import DocsComponents from '@/components/DocsComponents'
|
||||
import DocsExample from '@/components/DocsExample'
|
||||
import DocsIcons from '@/components/DocsIcons'
|
||||
|
||||
import { CButton } from '@coreui/vue'
|
||||
|
||||
// Create Vue application instance
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(CoreuiVue)
|
||||
|
||||
// Install plugins
|
||||
app.use(createPinia()) // State management
|
||||
app.use(router) // Router for SPA navigation
|
||||
app.use(CoreuiVue) // CoreUI component library
|
||||
|
||||
app.component('CButton', CButton)
|
||||
|
||||
// Provide icons globally
|
||||
app.provide('icons', icons)
|
||||
|
||||
// Register global components
|
||||
app.component('CIcon', CIcon)
|
||||
app.component('DocsComponents', DocsComponents)
|
||||
app.component('DocsExample', DocsExample)
|
||||
app.component('DocsIcons', DocsIcons)
|
||||
|
||||
// Mount application to DOM
|
||||
app.mount('#app')
|
||||
|
||||
+438
-4
@@ -1,14 +1,54 @@
|
||||
/**
|
||||
* router/index.js - Vue Router Configuration
|
||||
*
|
||||
* This file configures the application routing using Vue Router 5.
|
||||
* It defines all routes and navigation structure for the SPA.
|
||||
*
|
||||
* Routing Features:
|
||||
* - Hash-based routing (createWebHashHistory) for static hosting compatibility
|
||||
* - Lazy loading for all route components (code splitting)
|
||||
* - Nested routes for layout-based navigation
|
||||
* - Automatic scroll to top on navigation
|
||||
*
|
||||
* Route Structure:
|
||||
* - Protected routes: Wrapped in DefaultLayout with sidebar and header
|
||||
* - Public routes: Login, Register, 404, 500 pages without layout
|
||||
*
|
||||
* Adding New Routes:
|
||||
* 1. Import component (use dynamic import for code splitting)
|
||||
* 2. Add route object to appropriate section
|
||||
* 3. Update _nav.js for sidebar navigation (if needed)
|
||||
*
|
||||
* @see https://router.vuejs.org/
|
||||
*/
|
||||
|
||||
import { h, resolveComponent } from 'vue'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
import DefaultLayout from '@/layouts/DefaultLayout'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useMenuStore } from '@/stores/menu'
|
||||
import { hasToken } from '@/utils/session'
|
||||
|
||||
/**
|
||||
* Application routes configuration
|
||||
* @type {Array<Object>}
|
||||
*/
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
name: 'Website',
|
||||
component: () => import('@/views/website/LandingPage.vue'),
|
||||
meta: { guestLanding: true },
|
||||
},
|
||||
{
|
||||
path: '/app',
|
||||
name: 'Application',
|
||||
component: DefaultLayout,
|
||||
redirect: '/dashboard',
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/dashboard',
|
||||
@@ -17,9 +57,70 @@ const routes = [
|
||||
// this generates a separate chunk (about.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "dashboard" */ '@/views/dashboard/Dashboard.vue'
|
||||
),
|
||||
import(/* webpackChunkName: "dashboard" */ '@/views/dashboard/Dashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/profile/Profile.vue'),
|
||||
},
|
||||
{
|
||||
path: '/forbidden',
|
||||
name: 'Forbidden',
|
||||
component: () => import('@/views/pages/Page403.vue'),
|
||||
meta: { publicWithinApp: true },
|
||||
},
|
||||
{
|
||||
path: '/my-applications',
|
||||
name: 'Pengajuan Saya',
|
||||
component: () => import('@/views/access-applications/MyApplications.vue'),
|
||||
meta: { publicWithinApp: true },
|
||||
},
|
||||
{
|
||||
path: '/tickets',
|
||||
name: 'Tickets',
|
||||
component: {
|
||||
render() {
|
||||
return h(resolveComponent('router-view'))
|
||||
},
|
||||
},
|
||||
redirect: '/tickets/ticket',
|
||||
children: [
|
||||
{
|
||||
path: '/tickets/ticket',
|
||||
name: 'Ticket',
|
||||
component: () => import('@/views/tickets/Ticket.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tickets/ticket-type',
|
||||
alias: '/ticket-types',
|
||||
name: 'Ticket Type',
|
||||
component: () => import('@/views/tickets/TicketType.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tickets/ticket-materials',
|
||||
alias: '/materials',
|
||||
name: 'Ticket Materials',
|
||||
component: () => import('@/views/tickets/TicketMaterials.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tickets/ticket-incident',
|
||||
alias: '/ticket-incident-types',
|
||||
name: 'Ticket Incident',
|
||||
component: () => import('@/views/tickets/TicketIncident.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tickets/approved',
|
||||
alias: '/tickets/approval',
|
||||
name: 'Approved',
|
||||
component: () => import('@/views/tickets/Approved.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tickets/rejected',
|
||||
name: 'Rejected',
|
||||
component: () => import('@/views/tickets/Rejected.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/theme',
|
||||
@@ -66,6 +167,11 @@ const routes = [
|
||||
name: 'Carousels',
|
||||
component: () => import('@/views/base/Carousels.vue'),
|
||||
},
|
||||
{
|
||||
path: '/base/chips',
|
||||
name: 'Chips',
|
||||
component: () => import('@/views/base/Chips.vue'),
|
||||
},
|
||||
{
|
||||
path: '/base/collapses',
|
||||
name: 'Collapses',
|
||||
@@ -175,6 +281,11 @@ const routes = [
|
||||
name: 'Checks & Radios',
|
||||
component: () => import('@/views/forms/ChecksRadios.vue'),
|
||||
},
|
||||
{
|
||||
path: '/forms/chip-input',
|
||||
name: 'Chip Input',
|
||||
component: () => import('@/views/forms/ChipInput.vue'),
|
||||
},
|
||||
{
|
||||
path: '/forms/range',
|
||||
name: 'Range',
|
||||
@@ -266,6 +377,259 @@ const routes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/group-menus',
|
||||
name: 'Group Menus',
|
||||
component: () => import('@/views/group-menus/GroupMenus.vue'),
|
||||
},
|
||||
{
|
||||
path: '/group-menus/:id/menus',
|
||||
name: 'Group Menu Permissions',
|
||||
component: () => import('@/views/group-menus/GroupMenuPermissions.vue'),
|
||||
meta: { activeMenu: '/group-menus' },
|
||||
},
|
||||
{
|
||||
path: '/nas/mikrotik',
|
||||
name: 'NAS Mikrotik',
|
||||
component: () => import('@/views/nas/NasResourceView.vue'),
|
||||
meta: { nasResource: 'mikrotik' },
|
||||
},
|
||||
{
|
||||
path: '/nas/package-profiles',
|
||||
name: 'NAS Profile Paket',
|
||||
component: () => import('@/views/nas/NasResourceView.vue'),
|
||||
meta: { nasResource: 'package-profile' },
|
||||
},
|
||||
{
|
||||
path: '/nas/olt',
|
||||
name: 'NAS OLT',
|
||||
component: () => import('@/views/nas/NasResourceView.vue'),
|
||||
meta: { nasResource: 'olt' },
|
||||
},
|
||||
{
|
||||
path: '/nas/webfig',
|
||||
name: 'NAS Webfig',
|
||||
component: () => import('@/views/nas/NasResourceView.vue'),
|
||||
meta: { nasResource: 'webfig' },
|
||||
},
|
||||
{
|
||||
path: '/customers/orders',
|
||||
name: 'Customer List Order',
|
||||
component: () => import('@/views/customers/Customers.vue'),
|
||||
meta: { customerScope: 'orders' },
|
||||
},
|
||||
{
|
||||
path: '/customers/orders/create',
|
||||
name: 'Registrasi Customer',
|
||||
component: () => import('@/views/customers/CustomerFormPage.vue'),
|
||||
meta: { activeMenu: '/customers/orders' },
|
||||
},
|
||||
{
|
||||
path: '/customers/orders/:id/edit',
|
||||
name: 'Edit Customer',
|
||||
component: () => import('@/views/customers/CustomerFormPage.vue'),
|
||||
meta: { activeMenu: '/customers/orders' },
|
||||
},
|
||||
{
|
||||
path: '/customers/:scope/:id/detail',
|
||||
name: 'Detail Customer',
|
||||
component: () => import('@/views/customers/CustomerDetailPage.vue'),
|
||||
meta: { activeMenuBase: '/customers' },
|
||||
},
|
||||
{
|
||||
path: '/customers/active',
|
||||
name: 'Customer Aktif',
|
||||
component: () => import('@/views/customers/Customers.vue'),
|
||||
meta: { customerScope: 'active' },
|
||||
},
|
||||
{
|
||||
path: '/customers/inactive',
|
||||
name: 'Customer Tidak Aktif',
|
||||
component: () => import('@/views/customers/Customers.vue'),
|
||||
meta: { customerScope: 'inactive' },
|
||||
},
|
||||
{
|
||||
path: '/customers/unmanaged',
|
||||
name: 'Customer Unmanage',
|
||||
component: () => import('@/views/customers/Customers.vue'),
|
||||
meta: { customerScope: 'unmanaged' },
|
||||
},
|
||||
{
|
||||
path: '/customers/trash',
|
||||
name: 'Sampah Customer',
|
||||
component: () => import('@/views/customers/Customers.vue'),
|
||||
meta: { customerScope: 'trash' },
|
||||
},
|
||||
{
|
||||
path: '/billing/profiles',
|
||||
name: 'Profile Tagihan',
|
||||
component: () => import('@/views/billing/BillingProfiles.vue'),
|
||||
},
|
||||
{
|
||||
path: '/billing/running',
|
||||
name: 'Tagihan Berjalan',
|
||||
component: () => import('@/views/billing/Invoices.vue'),
|
||||
meta: { invoiceScope: 'running' },
|
||||
},
|
||||
{
|
||||
path: '/billing/overdue',
|
||||
name: 'Tunggakan',
|
||||
component: () => import('@/views/billing/Invoices.vue'),
|
||||
meta: { invoiceScope: 'overdue' },
|
||||
},
|
||||
{
|
||||
path: '/billing/paid',
|
||||
name: 'Tagihan Lunas',
|
||||
component: () => import('@/views/billing/Invoices.vue'),
|
||||
meta: { invoiceScope: 'paid' },
|
||||
},
|
||||
{
|
||||
path: '/topology/map',
|
||||
name: 'Peta Jaringan',
|
||||
component: () => import('@/views/topology/TopologyMap.vue'),
|
||||
},
|
||||
{
|
||||
path: '/topology/devices',
|
||||
name: 'Perangkat Topologi',
|
||||
component: () => import('@/views/topology/TopologyResourceView.vue'),
|
||||
meta: { topologyResource: 'node' },
|
||||
},
|
||||
{
|
||||
path: '/topology/links',
|
||||
name: 'Jalur Kabel',
|
||||
component: () => import('@/views/topology/TopologyResourceView.vue'),
|
||||
meta: { topologyResource: 'link' },
|
||||
},
|
||||
{
|
||||
path: '/topology/network',
|
||||
name: 'Topologi Jaringan',
|
||||
component: () => import('@/views/topology/TopologyNetwork.vue'),
|
||||
},
|
||||
{
|
||||
path: '/topology/device-types',
|
||||
name: 'Kategori Perangkat',
|
||||
component: () => import('@/views/topology/TopologyResourceView.vue'),
|
||||
meta: { topologyResource: 'device-type' },
|
||||
},
|
||||
{
|
||||
path: '/notifications-service/broadcasts',
|
||||
name: 'Pesan Siaran',
|
||||
component: () => import('@/views/notifications-service/NotificationBroadcasts.vue'),
|
||||
},
|
||||
{
|
||||
path: '/notifications-service/system',
|
||||
name: 'Notifikasi Aplikasi',
|
||||
component: () => import('@/views/notifications-service/NotificationChannelDashboard.vue'),
|
||||
meta: { notificationChannel: 'system' },
|
||||
},
|
||||
{
|
||||
path: '/notifications-service/whatsapp-official',
|
||||
name: 'WhatsApp Official',
|
||||
component: () => import('@/views/notifications-service/NotificationChannelDashboard.vue'),
|
||||
meta: { notificationChannel: 'whatsapp_official' },
|
||||
},
|
||||
{
|
||||
path: '/notifications-service/whatsapp-unofficial',
|
||||
name: 'WhatsApp Unofficial',
|
||||
component: () => import('@/views/notifications-service/NotificationChannelDashboard.vue'),
|
||||
meta: { notificationChannel: 'whatsapp_unofficial' },
|
||||
},
|
||||
{
|
||||
path: '/notifications-service/email',
|
||||
name: 'Email Notifikasi',
|
||||
component: () => import('@/views/notifications-service/NotificationChannelDashboard.vue'),
|
||||
meta: { notificationChannel: 'email' },
|
||||
},
|
||||
{
|
||||
path: '/notifications-service/telegram',
|
||||
name: 'Telegram Notifikasi',
|
||||
component: () => import('@/views/notifications-service/NotificationChannelDashboard.vue'),
|
||||
meta: { notificationChannel: 'telegram' },
|
||||
},
|
||||
{
|
||||
path: '/payment-gateway/settings',
|
||||
name: 'Setting Payment Gateway',
|
||||
component: () => import('@/views/payment-gateway/PaymentGatewaySettings.vue'),
|
||||
},
|
||||
{
|
||||
path: '/payment-gateway/logs',
|
||||
name: 'Log Pembayaran',
|
||||
component: () => import('@/views/payment-gateway/PaymentLogs.vue'),
|
||||
},
|
||||
{
|
||||
path: '/deposit-balance',
|
||||
alias: '/wallet',
|
||||
name: 'Saldo Deposit',
|
||||
component: () => import('@/views/wallet/WalletDashboard.vue'),
|
||||
meta: { activeMenu: '/deposit-balance' },
|
||||
},
|
||||
{
|
||||
path: '/voucher-hotspot/sales',
|
||||
name: 'Jual Voucher Hotspot',
|
||||
component: () => import('@/views/voucher-hotspot/VoucherSales.vue'),
|
||||
},
|
||||
{
|
||||
path: '/voucher-hotspot/agents',
|
||||
name: 'Agen Voucher',
|
||||
component: () => import('@/views/voucher-hotspot/VoucherAgents.vue'),
|
||||
},
|
||||
{
|
||||
path: '/voucher-hotspot/login-page',
|
||||
name: 'Voucher LoginPage',
|
||||
component: () => import('@/views/voucher-hotspot/VoucherLoginPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/access-applications/tenants',
|
||||
name: 'Pengajuan Tenant',
|
||||
component: () => import('@/views/access-applications/ApplicationReviews.vue'),
|
||||
meta: { applicationType: 'tenant' },
|
||||
},
|
||||
{
|
||||
path: '/access-applications/agents',
|
||||
name: 'Pengajuan Agen',
|
||||
component: () => import('@/views/access-applications/ApplicationReviews.vue'),
|
||||
meta: { applicationType: 'agent' },
|
||||
},
|
||||
{
|
||||
path: '/access-applications/staff',
|
||||
name: 'Pengajuan Staff',
|
||||
component: () => import('@/views/access-applications/ApplicationReviews.vue'),
|
||||
meta: { applicationType: 'staff' },
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'Users',
|
||||
component: () => import('@/views/users/Users.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tenants',
|
||||
name: 'Tenants',
|
||||
component: () => import('@/views/tenants/Tenants.vue'),
|
||||
},
|
||||
{
|
||||
path: '/wilayah/desa',
|
||||
name: 'Wilayah Desa',
|
||||
component: () => import('@/views/wilayah/Wilayah.vue'),
|
||||
meta: { tingkat: 'desa' },
|
||||
},
|
||||
{
|
||||
path: '/wilayah/kecamatan',
|
||||
name: 'Wilayah Kecamatan',
|
||||
component: () => import('@/views/wilayah/Wilayah.vue'),
|
||||
meta: { tingkat: 'kecamatan' },
|
||||
},
|
||||
{
|
||||
path: '/wilayah/kabupaten',
|
||||
name: 'Wilayah Kabupaten',
|
||||
component: () => import('@/views/wilayah/Wilayah.vue'),
|
||||
meta: { tingkat: 'kabupaten' },
|
||||
},
|
||||
{
|
||||
path: '/wilayah/provinsi',
|
||||
name: 'Wilayah Provinsi',
|
||||
component: () => import('@/views/wilayah/Wilayah.vue'),
|
||||
meta: { tingkat: 'provinsi' },
|
||||
},
|
||||
{
|
||||
path: '/widgets',
|
||||
name: 'Widgets',
|
||||
@@ -297,11 +661,25 @@ const routes = [
|
||||
path: 'login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/pages/Login'),
|
||||
meta: {
|
||||
guestOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/pages/Register'),
|
||||
meta: {
|
||||
guestOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'verify-code',
|
||||
name: 'VerifyCode',
|
||||
component: () => import('@/views/pages/VerifyCode'),
|
||||
meta: {
|
||||
guestOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -316,4 +694,60 @@ const router = createRouter({
|
||||
},
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth)
|
||||
const guestOnly = to.matched.some((record) => record.meta.guestOnly)
|
||||
const authenticated = hasToken()
|
||||
|
||||
if (requiresAuth && !authenticated) {
|
||||
return {
|
||||
name: 'Login',
|
||||
query: {
|
||||
redirect: to.fullPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (guestOnly && authenticated) {
|
||||
return { name: 'Dashboard' }
|
||||
}
|
||||
|
||||
if (requiresAuth) {
|
||||
try {
|
||||
const menuStore = useMenuStore()
|
||||
await menuStore.fetchMenus()
|
||||
|
||||
const publicWithinApp = to.matched.some((record) => record.meta.publicWithinApp)
|
||||
const alwaysAllowed = ['/dashboard', '/profile', '/forbidden', '/my-applications']
|
||||
|
||||
if (!publicWithinApp && !alwaysAllowed.includes(to.path)) {
|
||||
let accessUrl = to.meta.activeMenu || to.path
|
||||
|
||||
if (to.meta.activeMenuBase && to.params.scope) {
|
||||
accessUrl = `${to.meta.activeMenuBase}/${to.params.scope}`
|
||||
}
|
||||
|
||||
if (!menuStore.canAccessUrl(accessUrl)) {
|
||||
return {
|
||||
name: 'Forbidden',
|
||||
query: { from: to.fullPath },
|
||||
replace: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
useAuthStore().logout()
|
||||
|
||||
return {
|
||||
name: 'Login',
|
||||
query: {
|
||||
redirect: to.fullPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export async function login(payload) {
|
||||
const response = await api.post('/login', payload)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function register(payload) {
|
||||
const response = await api.post('/register', payload)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function verifyCode(payload) {
|
||||
const response = await api.post('/register/verify', payload)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function resendVerificationCode(payload) {
|
||||
const response = await api.post('/register/resend-code', payload)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export async function getBillingProfiles(params = {}) { return (await api.get('/billing/profiles', { params })).data }
|
||||
export async function getBillingProfile(id) { return (await api.get(`/billing/profiles/${id}`)).data }
|
||||
export async function createBillingProfile(payload) { return (await api.post('/billing/profiles', payload)).data }
|
||||
export async function updateBillingProfile(id, payload) { return (await api.put(`/billing/profiles/${id}`, payload)).data }
|
||||
export async function deleteBillingProfile(id) { return (await api.delete(`/billing/profiles/${id}`)).data }
|
||||
export async function getInvoices(scope, params = {}) { return (await api.get(`/billing/invoices/${scope}`, { params })).data }
|
||||
export async function getInvoice(scope, id) { return (await api.get(`/billing/invoices/${scope}/${id}`)).data }
|
||||
export async function getInvoiceOptions() { return (await api.get('/billing/invoice-options')).data }
|
||||
export async function createInvoice(payload) { return (await api.post('/billing/invoices', payload)).data }
|
||||
export async function updateInvoice(id, payload) { return (await api.put(`/billing/invoices/${id}`, payload)).data }
|
||||
export async function payInvoice(scope, id, payload) { return (await api.post(`/billing/invoices/${scope}/${id}/pay`, payload)).data }
|
||||
export async function cancelInvoice(id) { return (await api.post(`/billing/invoices/${id}/cancel`)).data }
|
||||
@@ -0,0 +1,42 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export async function getCustomers(scope, params = {}) {
|
||||
const { data } = await api.get(`/customers/${scope}`, { params })
|
||||
return data
|
||||
}
|
||||
export async function getCustomerOptions() {
|
||||
const { data } = await api.get('/customers/options')
|
||||
return data
|
||||
}
|
||||
export async function getCustomer(scope, id) {
|
||||
const { data } = await api.get(`/customers/${scope}/${id}`)
|
||||
return data
|
||||
}
|
||||
export async function createCustomer(payload) {
|
||||
const { data } = await api.post('/customers', payload)
|
||||
return data
|
||||
}
|
||||
export async function updateCustomer(id, payload) {
|
||||
const { data } = await api.put(`/customers/${id}`, payload)
|
||||
return data
|
||||
}
|
||||
export async function activateCustomer(id, payload) {
|
||||
const { data } = await api.post(`/customers/${id}/activate`, payload)
|
||||
return data
|
||||
}
|
||||
export async function deactivateCustomer(id) {
|
||||
const { data } = await api.post(`/customers/${id}/deactivate`)
|
||||
return data
|
||||
}
|
||||
export async function trashCustomer(id) {
|
||||
const { data } = await api.delete(`/customers/${id}`)
|
||||
return data
|
||||
}
|
||||
export async function restoreCustomer(id) {
|
||||
const { data } = await api.post(`/customers/${id}/restore`)
|
||||
return data
|
||||
}
|
||||
export async function forceDeleteCustomer(id) {
|
||||
const { data } = await api.delete(`/customers/${id}/force`)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export async function uploadFile(file, category = 'general') {
|
||||
const payload = new FormData()
|
||||
payload.append('file', file)
|
||||
payload.append('category', category)
|
||||
|
||||
const { data } = await api.post('/files', payload, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getTemporaryFileUrl(uuid) {
|
||||
const { data } = await api.get(`/files/${uuid}/temporary-url`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteFile(uuid) {
|
||||
const { data } = await api.delete(`/files/${uuid}`)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export const materialService = {
|
||||
fetchAll(params = {}) {
|
||||
return api.get('/materials', { params })
|
||||
},
|
||||
fetchByUser(userId) {
|
||||
return api.get(`/materials/user/${userId}`)
|
||||
},
|
||||
fetchHistory(barcodeId) {
|
||||
return api.get(`/materials/history/${barcodeId}`)
|
||||
},
|
||||
assign(payload) {
|
||||
return api.post('/materials/assign', payload)
|
||||
},
|
||||
transfer(payload) {
|
||||
return api.post('/materials/transfer', payload)
|
||||
},
|
||||
returnMaterial(payload) {
|
||||
return api.post('/materials/return', payload)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export async function getMenuGroups(params = {}) {
|
||||
const { data } = await api.get('/menu-groups', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createMenuGroup(payload) {
|
||||
const { data } = await api.post('/menu-groups', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getMenuGroupById(id) {
|
||||
const { data } = await api.get(`/menu-groups/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateMenuGroup(id, payload) {
|
||||
const { data } = await api.put(`/menu-groups/${id}`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteMenuGroup(id) {
|
||||
const { data } = await api.delete(`/menu-groups/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getMenuGroupAvailableById(id) {
|
||||
const { data } = await api.get(`/menu-groups/${id}/available-menus`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function syncMenuGroupMenus(id, payload) {
|
||||
const { data } = await api.put(`/menu-groups/${id}/menus`, payload)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export async function getMyMenus() {
|
||||
const { data } = await api.get('/me/menus')
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
const paths = {
|
||||
mikrotik: 'mikrotiks',
|
||||
'package-profile': 'package-profiles',
|
||||
olt: 'olts',
|
||||
webfig: 'webfig-devices',
|
||||
}
|
||||
|
||||
function endpoint(resource, id = null) {
|
||||
const base = `/nas/${paths[resource]}`
|
||||
return id ? `${base}/${id}` : base
|
||||
}
|
||||
|
||||
export async function getNasResources(resource, params = {}) {
|
||||
const { data } = await api.get(endpoint(resource), { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getNasResource(resource, id) {
|
||||
const { data } = await api.get(endpoint(resource, id))
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createNasResource(resource, payload) {
|
||||
const { data } = await api.post(endpoint(resource), payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateNasResource(resource, id, payload) {
|
||||
const { data } = await api.put(endpoint(resource, id), payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteNasResource(resource, id) {
|
||||
const { data } = await api.delete(endpoint(resource, id))
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
const channelPaths = {
|
||||
system: 'system',
|
||||
whatsapp_official: 'whatsapp-official',
|
||||
whatsapp_unofficial: 'whatsapp-unofficial',
|
||||
email: 'email',
|
||||
telegram: 'telegram',
|
||||
}
|
||||
|
||||
const channelBase = (channel) => `/notifications/channels/${channelPaths[channel]}`
|
||||
|
||||
export async function getNotificationEvents() {
|
||||
return (await api.get('/notifications/events')).data
|
||||
}
|
||||
export async function getNotificationChannels(channel) {
|
||||
return (await api.get(`${channelBase(channel)}/settings`)).data
|
||||
}
|
||||
export async function createNotificationChannel(channel, payload) {
|
||||
return (await api.post(`${channelBase(channel)}/settings`, payload)).data
|
||||
}
|
||||
export async function updateNotificationChannel(channel, id, payload) {
|
||||
return (await api.put(`${channelBase(channel)}/settings/${id}`, payload)).data
|
||||
}
|
||||
export async function testNotificationChannel(channel, id) {
|
||||
return (await api.post(`${channelBase(channel)}/settings/${id}/test`)).data
|
||||
}
|
||||
export async function deleteNotificationChannel(channel, id) {
|
||||
return (await api.delete(`${channelBase(channel)}/settings/${id}`)).data
|
||||
}
|
||||
export async function getSystemNotificationCampaigns(params = {}) {
|
||||
return (await api.get('/notifications/system/campaigns', { params })).data
|
||||
}
|
||||
export async function createSystemNotificationCampaign(payload) {
|
||||
return (await api.post('/notifications/system/campaigns', payload)).data
|
||||
}
|
||||
export async function getSystemAudienceOptions(params = {}) {
|
||||
return (await api.get('/notifications/system/audience-options', { params })).data
|
||||
}
|
||||
export async function getWhapiState(id) {
|
||||
return (await api.get(`/notifications/channels/whatsapp-unofficial/settings/${id}/state`)).data
|
||||
}
|
||||
export async function startWhapi(id) {
|
||||
return (await api.post(`/notifications/channels/whatsapp-unofficial/settings/${id}/start`)).data
|
||||
}
|
||||
export async function getWhapiQr(id) {
|
||||
return (await api.get(`/notifications/channels/whatsapp-unofficial/settings/${id}/qr`)).data
|
||||
}
|
||||
export async function sendWhapiTestMessage(id, payload) {
|
||||
return (
|
||||
await api.post(
|
||||
`/notifications/channels/whatsapp-unofficial/settings/${id}/test-message`,
|
||||
payload,
|
||||
)
|
||||
).data
|
||||
}
|
||||
export async function getNotificationTemplates(channel) {
|
||||
return (await api.get(`${channelBase(channel)}/templates`)).data
|
||||
}
|
||||
export async function createNotificationTemplate(channel, payload) {
|
||||
return (await api.post(`${channelBase(channel)}/templates`, payload)).data
|
||||
}
|
||||
export async function updateNotificationTemplate(channel, id, payload) {
|
||||
return (await api.put(`${channelBase(channel)}/templates/${id}`, payload)).data
|
||||
}
|
||||
export async function getNotificationLogs(channel, params = {}) {
|
||||
return (await api.get(`${channelBase(channel)}/logs`, { params })).data
|
||||
}
|
||||
export async function getNotificationPreferences(params = {}) {
|
||||
return (await api.get('/notifications/preferences', { params })).data
|
||||
}
|
||||
export async function saveNotificationPreferences(preferences) {
|
||||
return (await api.put('/notifications/preferences', { preferences })).data
|
||||
}
|
||||
export async function getBroadcasts(params = {}) {
|
||||
return (await api.get('/notifications/broadcasts', { params })).data
|
||||
}
|
||||
export async function previewBroadcast(payload) {
|
||||
return (await api.post('/notifications/broadcasts/preview', payload)).data
|
||||
}
|
||||
export async function createBroadcast(payload) {
|
||||
return (await api.post('/notifications/broadcasts', payload)).data
|
||||
}
|
||||
export async function approveBroadcast(id) {
|
||||
return (await api.post(`/notifications/broadcasts/${id}/approve`)).data
|
||||
}
|
||||
export async function getMyNotifications(params = {}) {
|
||||
return (await api.get('/me/notifications', { params })).data
|
||||
}
|
||||
export async function markNotificationRead(uuid) {
|
||||
return (await api.put(`/me/notifications/${uuid}/read`)).data
|
||||
}
|
||||
export async function getCustomerNotificationContacts(customerId) {
|
||||
return (await api.get(`/customers/${customerId}/notification-contacts`)).data
|
||||
}
|
||||
export async function createCustomerNotificationContact(customerId, payload) {
|
||||
return (await api.post(`/customers/${customerId}/notification-contacts`, payload)).data
|
||||
}
|
||||
export async function updateCustomerNotificationContact(customerId, id, payload) {
|
||||
return (await api.put(`/customers/${customerId}/notification-contacts/${id}`, payload)).data
|
||||
}
|
||||
export async function deleteCustomerNotificationContact(customerId, id) {
|
||||
return (await api.delete(`/customers/${customerId}/notification-contacts/${id}`)).data
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export const getPaymentProviders = async () => (await api.get('/payment-gateway/providers')).data
|
||||
export const getPaymentSettings = async () => (await api.get('/payment-gateway/settings')).data
|
||||
export const createGatewayAccount = async (payload) => (await api.post('/payment-gateway/accounts', payload)).data
|
||||
export const updateGatewayAccount = async (id, payload) => (await api.put(`/payment-gateway/accounts/${id}`, payload)).data
|
||||
export const saveTenantPaymentSetting = async (payload) => (await api.put('/payment-gateway/tenant-setting', payload)).data
|
||||
export const getPaymentTransactions = async (params = {}) => (await api.get('/payment-gateway/transactions', { params })).data
|
||||
@@ -0,0 +1,11 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export async function getMyProfile() {
|
||||
const { data } = await api.get('/me/profile')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateMyProfile(payload) {
|
||||
const { data } = await api.put('/me/profile', payload)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export const getMyRoleApplications = async () => (await api.get('/me/role-applications')).data
|
||||
export const createRoleApplication = async (payload) =>
|
||||
(await api.post('/me/role-applications', payload)).data
|
||||
export const getRoleApplications = async (type, params = {}) =>
|
||||
(await api.get(`/role-applications/${type}`, { params })).data
|
||||
export const getTenantMenuGroupOptions = async () =>
|
||||
(await api.get('/role-applications/tenant/menu-group-options')).data
|
||||
export const getStaffMenuGroupOptions = async () =>
|
||||
(await api.get('/role-applications/staff/menu-group-options')).data
|
||||
export const reviewRoleApplication = async (id, payload) =>
|
||||
(await api.put(`/role-applications/${id}/review`, payload)).data
|
||||
export const linkUserCustomer = async (payload) =>
|
||||
(await api.post('/customer-user-links', payload)).data
|
||||
@@ -0,0 +1,26 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export async function getTenants(params = {}) {
|
||||
const { data } = await api.get('/tenants', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createTenant(payload) {
|
||||
const { data } = await api.post('/tenants', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getTenantById(id) {
|
||||
const { data } = await api.get(`/tenants/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateTenant(id, payload) {
|
||||
const { data } = await api.put(`/tenants/${id}`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteTenant(id) {
|
||||
const { data } = await api.delete(`/tenants/${id}`)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
const paths = {
|
||||
'device-type': 'device-types',
|
||||
node: 'nodes',
|
||||
link: 'links',
|
||||
}
|
||||
|
||||
const endpoint = (resource, id = null) => {
|
||||
const base = `/topology/${paths[resource]}`
|
||||
return id ? `${base}/${id}` : base
|
||||
}
|
||||
|
||||
export async function getTopologyResources(resource, params = {}) {
|
||||
return (await api.get(endpoint(resource), { params })).data
|
||||
}
|
||||
|
||||
export async function getTopologyResource(resource, id) {
|
||||
return (await api.get(endpoint(resource, id))).data
|
||||
}
|
||||
|
||||
export async function createTopologyResource(resource, payload) {
|
||||
return (await api.post(endpoint(resource), payload)).data
|
||||
}
|
||||
|
||||
export async function updateTopologyResource(resource, id, payload) {
|
||||
return (await api.put(endpoint(resource, id), payload)).data
|
||||
}
|
||||
|
||||
export async function deleteTopologyResource(resource, id) {
|
||||
return (await api.delete(endpoint(resource, id))).data
|
||||
}
|
||||
|
||||
export async function getTopologyOptions() {
|
||||
return (await api.get('/topology/options')).data
|
||||
}
|
||||
|
||||
export async function getTopologyMap(params = {}) {
|
||||
return (await api.get('/topology/map', { params })).data
|
||||
}
|
||||
|
||||
export async function getNodePorts(nodeId) {
|
||||
return (await api.get(`/topology/nodes/${nodeId}/ports`)).data
|
||||
}
|
||||
|
||||
export async function createNodePort(nodeId, payload) {
|
||||
return (await api.post(`/topology/nodes/${nodeId}/ports`, payload)).data
|
||||
}
|
||||
|
||||
export async function updateNodePort(id, payload) {
|
||||
return (await api.put(`/topology/ports/${id}`, payload)).data
|
||||
}
|
||||
|
||||
export async function deleteNodePort(id) {
|
||||
return (await api.delete(`/topology/ports/${id}`)).data
|
||||
}
|
||||
|
||||
export async function traceTopology(nodeId, direction = 'downstream') {
|
||||
return (await api.get(`/topology/nodes/${nodeId}/${direction}`)).data
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export async function getUsers(params = {}) {
|
||||
const { data } = await api.get('/users', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createUser(payload) {
|
||||
const { data } = await api.post('/users', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUserById(id) {
|
||||
const { data } = await api.get(`/users/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateUser(id, payload) {
|
||||
const { data } = await api.put(`/users/${id}`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteUser(id) {
|
||||
const { data } = await api.delete(`/users/${id}`)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export const getVoucherOptions = async (scope = 'sales') => (await api.get(`/voucher-hotspot/${scope === 'agents' ? 'agent-options' : scope === 'login-page' ? 'login-page-options' : 'sales-options'}`)).data
|
||||
export const getVoucherSales = async (params = {}) => (await api.get('/voucher-hotspot/sales', { params })).data
|
||||
export const getVoucherSale = async (id) => (await api.get(`/voucher-hotspot/sales/${id}`)).data
|
||||
export const createVoucherSale = async (payload) => (await api.post('/voucher-hotspot/sales', payload)).data
|
||||
export const getVoucherAgents = async (params = {}) => (await api.get('/voucher-hotspot/agents', { params })).data
|
||||
export const createVoucherAgent = async (payload) => (await api.post('/voucher-hotspot/agents', payload)).data
|
||||
export const updateVoucherAgent = async (id, payload) => (await api.put(`/voucher-hotspot/agents/${id}`, payload)).data
|
||||
export const deleteVoucherAgent = async (id) => (await api.delete(`/voucher-hotspot/agents/${id}`)).data
|
||||
export const getVoucherLoginPage = async () => (await api.get('/voucher-hotspot/login-page')).data
|
||||
export const saveVoucherLoginPage = async (payload) => (await api.put('/voucher-hotspot/login-page', payload)).data
|
||||
@@ -0,0 +1,5 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export const getMyWallets = async () => (await api.get('/wallets')).data
|
||||
export const getWalletLedger = async (id, params = {}) => (await api.get(`/wallets/${id}/ledger`, { params })).data
|
||||
export const withdrawWallet = async (id, payload) => (await api.post(`/wallets/${id}/withdraw`, payload)).data
|
||||
@@ -0,0 +1,51 @@
|
||||
import api from '@/api/axios'
|
||||
|
||||
export async function getWilayah(tingkat, params = {}) {
|
||||
const { data } = await api.get(`/wilayah/${tingkat}`, { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getWilayahById(tingkat, id) {
|
||||
const { data } = await api.get(`/wilayah/${tingkat}/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createWilayah(tingkat, payload) {
|
||||
const { data } = await api.post(`/wilayah/${tingkat}`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateWilayah(tingkat, id, payload) {
|
||||
const { data } = await api.put(`/wilayah/${tingkat}/${id}`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteWilayah(tingkat, id) {
|
||||
const { data } = await api.delete(`/wilayah/${tingkat}/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getAlamatWilayah(params) {
|
||||
const { data } = await api.get('/wilayah/alamat-lengkap', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getProvinsiOptions(params = {}) {
|
||||
const { data } = await api.get('/wilayah/options/provinsi', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getKabupatenOptions(provinsiId, params = {}) {
|
||||
const { data } = await api.get(`/wilayah/options/kabupaten/${provinsiId}`, { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getKecamatanOptions(kabupatenId, params = {}) {
|
||||
const { data } = await api.get(`/wilayah/options/kecamatan/${kabupatenId}`, { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getDesaOptions(kecamatanId, params = {}) {
|
||||
const { data } = await api.get(`/wilayah/options/desa/${kecamatanId}`, { params })
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
login as loginApi,
|
||||
register as registerApi,
|
||||
verifyCode as verifyCodeApi,
|
||||
resendVerificationCode as resendVerificationCodeApi,
|
||||
} from '@/services/authService'
|
||||
import {
|
||||
getAuthenticatedUser,
|
||||
removeAuthenticatedUser,
|
||||
removeToken,
|
||||
setAuthenticatedUser,
|
||||
setToken,
|
||||
} from '@/utils/session'
|
||||
import { useMenuStore } from '@/stores/menu'
|
||||
import {
|
||||
setVerificationContext,
|
||||
getVerificationContext,
|
||||
clearVerificationContext,
|
||||
} from '@/utils/verificationSession'
|
||||
|
||||
function extractErrorMessage(error) {
|
||||
const fallback = 'Terjadi kesalahan. Silakan coba lagi.'
|
||||
const data = error?.response?.data
|
||||
|
||||
if (!data) return error?.message || fallback
|
||||
if (typeof data?.message === 'string') return data.message
|
||||
|
||||
if (data?.errors && typeof data.errors === 'object') {
|
||||
const firstKey = Object.keys(data.errors)[0]
|
||||
const firstError = data.errors[firstKey]
|
||||
if (Array.isArray(firstError) && firstError.length > 0) return firstError[0]
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => {
|
||||
const savedVerification = getVerificationContext() || {}
|
||||
|
||||
return {
|
||||
user: getAuthenticatedUser(),
|
||||
loading: false,
|
||||
error: null,
|
||||
pendingVerificationEmail: savedVerification.verification_target || '',
|
||||
verificationChannel: savedVerification.verification_channel || 'email',
|
||||
verificationTarget: savedVerification.verification_target || '',
|
||||
verificationCode: savedVerification.verification_code || '',
|
||||
verificationCodeExpiresAt: savedVerification.expires_at || null,
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
setUser(user) {
|
||||
this.user = user || null
|
||||
|
||||
if (this.user) {
|
||||
setAuthenticatedUser(this.user)
|
||||
} else {
|
||||
removeAuthenticatedUser()
|
||||
}
|
||||
},
|
||||
|
||||
clearError() {
|
||||
this.error = null
|
||||
},
|
||||
|
||||
async login(payload) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
let tokenWasStored = false
|
||||
|
||||
try {
|
||||
const data = await loginApi(payload)
|
||||
const token = data?.token || data?.access_token || data?.data?.token
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Token tidak ditemukan pada response login.')
|
||||
}
|
||||
|
||||
setToken(token)
|
||||
tokenWasStored = true
|
||||
this.setUser(data?.user || data?.data?.user || null)
|
||||
|
||||
await useMenuStore().fetchMenus({ force: true })
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
if (tokenWasStored) {
|
||||
removeToken()
|
||||
removeAuthenticatedUser()
|
||||
this.user = null
|
||||
useMenuStore().reset()
|
||||
this.error = 'Login berhasil, tetapi menu akses gagal dimuat. Silakan coba lagi.'
|
||||
} else {
|
||||
this.error = extractErrorMessage(error)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async register(payload) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const data = await registerApi(payload)
|
||||
|
||||
this.verificationChannel = payload?.verification_channel || 'email'
|
||||
this.verificationTarget = payload?.verification_target || payload?.email || ''
|
||||
this.pendingVerificationEmail = this.verificationTarget
|
||||
const verification = data?.data?.verification || data?.verification || {}
|
||||
this.verificationCode = verification?.code || ''
|
||||
this.verificationCodeExpiresAt = verification?.expires_at || null
|
||||
|
||||
setVerificationContext({
|
||||
verification_channel: this.verificationChannel,
|
||||
verification_target: this.verificationTarget,
|
||||
verification_code: this.verificationCode,
|
||||
expires_at: this.verificationCodeExpiresAt,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
this.error = extractErrorMessage(error)
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async verifyCode(payload) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const data = await verifyCodeApi(payload)
|
||||
const token = data?.token || data?.access_token || data?.data?.token
|
||||
|
||||
if (token) {
|
||||
setToken(token)
|
||||
}
|
||||
|
||||
this.setUser(data?.user || data?.data?.user || this.user)
|
||||
this.pendingVerificationEmail = ''
|
||||
this.verificationChannel = 'email'
|
||||
this.verificationTarget = ''
|
||||
this.verificationCode = ''
|
||||
this.verificationCodeExpiresAt = null
|
||||
clearVerificationContext()
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
this.error = extractErrorMessage(error)
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async resendVerificationCode(payload) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const data = await resendVerificationCodeApi(payload)
|
||||
const verification = data?.data?.verification || data?.verification || {}
|
||||
this.verificationCode = verification?.code || ''
|
||||
this.verificationCodeExpiresAt = verification?.expires_at || null
|
||||
|
||||
setVerificationContext({
|
||||
verification_channel: payload?.verification_channel || this.verificationChannel,
|
||||
verification_target: payload?.verification_target || this.verificationTarget,
|
||||
verification_code: this.verificationCode,
|
||||
expires_at: this.verificationCodeExpiresAt,
|
||||
})
|
||||
return data
|
||||
} catch (error) {
|
||||
this.error = extractErrorMessage(error)
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
logout() {
|
||||
removeToken()
|
||||
removeAuthenticatedUser()
|
||||
useMenuStore().reset()
|
||||
this.user = null
|
||||
this.error = null
|
||||
this.pendingVerificationEmail = ''
|
||||
this.verificationChannel = 'email'
|
||||
this.verificationTarget = ''
|
||||
this.verificationCode = ''
|
||||
this.verificationCodeExpiresAt = null
|
||||
clearVerificationContext()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { materialService } from '@/services/materialService'
|
||||
|
||||
export const useMaterialStore = defineStore('material', {
|
||||
state: () => ({
|
||||
materials: [],
|
||||
histories: [],
|
||||
loading: false,
|
||||
error: '',
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchAll(params = {}) {
|
||||
this.loading = true
|
||||
this.error = ''
|
||||
try {
|
||||
const res = await materialService.fetchAll({ per_page: 1000, ...params })
|
||||
const resData = res.data
|
||||
|
||||
if (Array.isArray(resData)) {
|
||||
this.materials = resData
|
||||
} else if (resData && Array.isArray(resData.data)) {
|
||||
this.materials = resData.data
|
||||
} else {
|
||||
console.warn('fetchAll: unexpected response', resData)
|
||||
this.materials = []
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || 'Gagal mengambil data material.'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async fetchByUser(userId) {
|
||||
if (!userId) return
|
||||
this.loading = true
|
||||
this.error = ''
|
||||
try {
|
||||
const res = await materialService.fetchByUser(userId)
|
||||
this.materials = res.data.data || []
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || 'Gagal mengambil data material.'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async fetchHistory(barcodeId) {
|
||||
if (!barcodeId) return
|
||||
this.loading = true
|
||||
this.error = ''
|
||||
try {
|
||||
const res = await materialService.fetchHistory(barcodeId)
|
||||
this.histories = res.data.data || []
|
||||
} catch (err) {
|
||||
this.error = err.response?.data?.message || 'Gagal mengambil history material.'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { getMyMenus } from '@/services/menuService'
|
||||
|
||||
let pendingMenuRequest = null
|
||||
|
||||
function extractErrorMessage(error) {
|
||||
const fallback = 'Gagal memuat menu.'
|
||||
const data = error?.response?.data
|
||||
|
||||
if (!data) return error?.message || fallback
|
||||
if (typeof data?.message === 'string') return data.message
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function mapMenuItem(item, isChild = false) {
|
||||
const hasChildren = Array.isArray(item?.children) && item.children.filter(Boolean).length > 0
|
||||
const name = item?.name || 'Untitled'
|
||||
const to = item?.url || '#'
|
||||
|
||||
if (hasChildren) {
|
||||
return {
|
||||
component: 'CNavGroup',
|
||||
name,
|
||||
to,
|
||||
...(isChild ? {} : { icon: item?.icon || 'cil-menu' }),
|
||||
items: item.children.filter(Boolean).map((child) => mapMenuItem(child, true)),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
component: 'CNavItem',
|
||||
name,
|
||||
to,
|
||||
...(isChild ? {} : { icon: item?.icon || 'cil-menu' }),
|
||||
}
|
||||
}
|
||||
|
||||
function flattenMenus(items = []) {
|
||||
return items.flatMap((item) => [
|
||||
item,
|
||||
...flattenMenus(Array.isArray(item?.children) ? item.children : []),
|
||||
])
|
||||
}
|
||||
|
||||
export const useMenuStore = defineStore('menu', {
|
||||
state: () => ({
|
||||
isMaster: false,
|
||||
dynamicMenus: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
initialized: false,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
mappedMenus(state) {
|
||||
return state.dynamicMenus.map((item) => mapMenuItem(item))
|
||||
},
|
||||
accessibleMenus(state) {
|
||||
return flattenMenus(state.dynamicMenus)
|
||||
},
|
||||
accessibleUrls() {
|
||||
return this.accessibleMenus
|
||||
.filter((item) => item?.permissions?.can_view !== false && item?.url && item.url !== '#')
|
||||
.map((item) => item.url)
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
canAccessUrl(url) {
|
||||
if (!url) return false
|
||||
|
||||
return this.accessibleUrls.some(
|
||||
(menuUrl) => url === menuUrl || url.startsWith(`${menuUrl}/`),
|
||||
)
|
||||
},
|
||||
async fetchMenus({ force = false } = {}) {
|
||||
if (this.initialized && !force) {
|
||||
return this.dynamicMenus
|
||||
}
|
||||
|
||||
if (pendingMenuRequest) {
|
||||
return pendingMenuRequest
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
pendingMenuRequest = (async () => {
|
||||
try {
|
||||
const response = await getMyMenus()
|
||||
const payload = response?.data || {}
|
||||
|
||||
this.isMaster = !!payload?.is_master
|
||||
this.dynamicMenus = Array.isArray(payload?.menus) ? payload.menus : []
|
||||
this.initialized = true
|
||||
|
||||
return this.dynamicMenus
|
||||
} catch (error) {
|
||||
this.error = extractErrorMessage(error)
|
||||
this.initialized = false
|
||||
this.dynamicMenus = []
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
pendingMenuRequest = null
|
||||
}
|
||||
})()
|
||||
|
||||
return pendingMenuRequest
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.isMaster = false
|
||||
this.dynamicMenus = []
|
||||
this.loading = false
|
||||
this.error = null
|
||||
this.initialized = false
|
||||
pendingMenuRequest = null
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useMenuGroupStore = defineStore('menuGroup', () => {
|
||||
const menuGroups = ref([])
|
||||
|
||||
function setMenuGroups(data) {
|
||||
menuGroups.value = data
|
||||
}
|
||||
|
||||
function addMenuGroup(item) {
|
||||
menuGroups.value.push({ ...item })
|
||||
}
|
||||
|
||||
function updateMenuGroup(index, item) {
|
||||
menuGroups.value[index] = { ...item }
|
||||
}
|
||||
|
||||
function deleteMenuGroup(index) {
|
||||
menuGroups.value.splice(index, 1)
|
||||
}
|
||||
|
||||
return { menuGroups, setMenuGroups, addMenuGroup, updateMenuGroup, deleteMenuGroup }
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useTicketStore = defineStore('ticket', () => {
|
||||
const tickets = ref([
|
||||
{
|
||||
ticket_no: 'TKT-001',
|
||||
tenant_id: 'tenant-01',
|
||||
customer_id: 'CUST-1001',
|
||||
deskripsi: 'Modem Merah',
|
||||
kode_tiket: 'GGN',
|
||||
status: 'Open',
|
||||
prioritas: 'Low',
|
||||
sla_minutes: '480',
|
||||
ditugaskan_untuk: 'Joko',
|
||||
created_by: 'admin@company.com',
|
||||
approved_by: 'manager@company.com',
|
||||
approved_at: '2026-06-20 09:00',
|
||||
rejected_by: '-',
|
||||
rejected_at: '-',
|
||||
rejection_reason: '-',
|
||||
assigned_at: '2026-06-20 09:30',
|
||||
resolved_at: '-',
|
||||
closed_at: '-',
|
||||
created_at: '2026-06-20 08:00',
|
||||
updated_at: '2026-06-20 09:30',
|
||||
},
|
||||
{
|
||||
ticket_no: 'TKT-002',
|
||||
tenant_id: 'tenant-01',
|
||||
customer_id: 'CUST-1002',
|
||||
deskripsi: 'Ganti Perangkat',
|
||||
kode_tiket: 'ONU',
|
||||
status: 'Open',
|
||||
prioritas: 'Medium',
|
||||
sla_minutes: '240',
|
||||
ditugaskan_untuk: 'Bowo',
|
||||
created_by: 'user@company.com',
|
||||
approved_by: '-',
|
||||
approved_at: '-',
|
||||
rejected_by: 'supervisor@company.com',
|
||||
rejected_at: '2026-06-22 10:00',
|
||||
rejection_reason: 'Duplicate request',
|
||||
assigned_at: '-',
|
||||
resolved_at: '-',
|
||||
closed_at: '-',
|
||||
created_at: '2026-06-22 09:00',
|
||||
updated_at: '2026-06-22 10:00',
|
||||
},
|
||||
{
|
||||
ticket_no: 'TKT-003',
|
||||
tenant_id: 'tenant-02',
|
||||
customer_id: 'CUST-1003',
|
||||
deskripsi: 'Perawatan Perangkat',
|
||||
kode_tiket: 'MNT',
|
||||
status: 'Closed',
|
||||
prioritas: 'High',
|
||||
sla_minutes: '720',
|
||||
ditugaskan_untuk: 'Bahlil',
|
||||
created_by: 'client@domain.com',
|
||||
approved_by: 'manager@company.com',
|
||||
approved_at: '2026-06-23 08:00',
|
||||
rejected_by: '-',
|
||||
rejected_at: '-',
|
||||
rejection_reason: '-',
|
||||
assigned_at: '2026-06-23 08:30',
|
||||
resolved_at: '2026-06-24 15:00',
|
||||
closed_at: '2026-06-24 16:00',
|
||||
created_at: '2026-06-23 07:00',
|
||||
updated_at: '2026-06-24 16:00',
|
||||
},
|
||||
])
|
||||
|
||||
const approvedTickets = ref([])
|
||||
const rejectedTickets = ref([])
|
||||
|
||||
function setTickets(data) {
|
||||
tickets.value = data
|
||||
}
|
||||
|
||||
function addTicket(ticket) {
|
||||
tickets.value.push({ ...ticket })
|
||||
}
|
||||
|
||||
function updateTicket(index, ticket) {
|
||||
tickets.value[index] = { ...ticket }
|
||||
}
|
||||
|
||||
function deleteTicket(index) {
|
||||
tickets.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function approveTicket(index, approver) {
|
||||
const now = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
tickets.value[index].approved_by = approver
|
||||
tickets.value[index].approved_at = now
|
||||
tickets.value[index].status = 'Approved'
|
||||
}
|
||||
|
||||
function rejectTicket(index, rejector, reason) {
|
||||
const now = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
tickets.value[index].rejected_by = rejector
|
||||
tickets.value[index].rejected_at = now
|
||||
tickets.value[index].rejection_reason = reason
|
||||
tickets.value[index].status = 'Rejected'
|
||||
}
|
||||
|
||||
return { tickets, setTickets, addTicket, updateTicket, deleteTicket, approveTicket, rejectTicket }
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useTicketIncidentStore = defineStore('ticketIncident', () => {
|
||||
const items = ref([])
|
||||
|
||||
function setItems(data) {
|
||||
items.value = data
|
||||
}
|
||||
|
||||
function addItem(item) {
|
||||
items.value.push({ ...item })
|
||||
}
|
||||
|
||||
function updateItem(index, item) {
|
||||
items.value[index] = { ...item }
|
||||
}
|
||||
|
||||
function deleteItem(index) {
|
||||
items.value.splice(index, 1)
|
||||
}
|
||||
|
||||
return { items, setItems, addItem, updateItem, deleteItem }
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useTicketTypeStore = defineStore('ticketType', () => {
|
||||
const ticketTypes = ref([])
|
||||
|
||||
function setTicketTypes(data) {
|
||||
ticketTypes.value = data
|
||||
}
|
||||
|
||||
function addTicketType(item) {
|
||||
ticketTypes.value.push({ ...item })
|
||||
}
|
||||
|
||||
function updateTicketType(index, item) {
|
||||
ticketTypes.value[index] = { ...item }
|
||||
}
|
||||
|
||||
function deleteTicketType(index) {
|
||||
ticketTypes.value.splice(index, 1)
|
||||
}
|
||||
|
||||
return { ticketTypes, setTicketTypes, addTicketType, updateTicketType, deleteTicketType }
|
||||
})
|
||||
@@ -0,0 +1,517 @@
|
||||
/* Reusable UI/UX style for BaseResponsiveDataView
|
||||
Prefix khusus: brdvx- (agar tidak bentrok dengan style existing) */
|
||||
|
||||
.brdvx-page-row {
|
||||
--cui-gutter-x: 0;
|
||||
margin-right: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.brdvx-page-column {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.brdvx-page-container {
|
||||
margin: 0;
|
||||
padding-right: 0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.brdvx-page-header {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.brdvx-root {
|
||||
--brdvx-font-size: 0.9rem;
|
||||
--brdvx-font-size-label: 0.78rem;
|
||||
--brdvx-font-size-heading: 0.84rem;
|
||||
--brdvx-font-weight-normal: 400;
|
||||
--brdvx-font-weight-medium: 500;
|
||||
--brdvx-font-weight-heading: 600;
|
||||
width: 100%;
|
||||
font-size: var(--brdvx-font-size);
|
||||
}
|
||||
|
||||
.brdvx-loading-state {
|
||||
display: flex;
|
||||
min-height: 220px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--cui-secondary-color);
|
||||
}
|
||||
|
||||
.brdvx-loading-text {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.brdvx-inline-loading {
|
||||
display: flex;
|
||||
min-height: 2.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--cui-secondary-color);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.brdvx-root .data-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.9rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.brdvx-root .toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.brdvx-root .per-page-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.brdvx-root .toolbar-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--brdvx-font-weight-normal);
|
||||
color: var(--cui-secondary-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brdvx-root .per-page-select {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.brdvx-root .mode-switch {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.brdvx-root .toolbar-search-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 220px;
|
||||
width: min(420px, 100%);
|
||||
}
|
||||
|
||||
.brdvx-root .toolbar-search {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.brdvx-root .view-toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brdvx-root .cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brdvx-root .cards-grid.mobile {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.brdvx-root .mobile-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--cui-border-color);
|
||||
box-shadow: 0 2px 12px rgba(33, 37, 41, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brdvx-root .mobile-image-header {
|
||||
width: 100%;
|
||||
max-height: 160px;
|
||||
aspect-ratio: 10 / 7;
|
||||
background: #f1f3f5;
|
||||
}
|
||||
|
||||
.brdvx-root .mobile-image {
|
||||
width: 100%;
|
||||
max-height: 160px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.brdvx-root .mobile-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-bottom: 1px solid var(--cui-border-color);
|
||||
background: var(--cui-tertiary-bg);
|
||||
}
|
||||
|
||||
.brdvx-root .mobile-card-title {
|
||||
font-weight: var(--brdvx-font-weight-heading);
|
||||
font-size: 0.95rem;
|
||||
color: var(--cui-body-color);
|
||||
}
|
||||
|
||||
.brdvx-root .mobile-card-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.brdvx-root .mobile-fields {
|
||||
display: grid;
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.brdvx-root .mobile-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(70px, 1fr) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.45rem;
|
||||
padding-bottom: 0.3rem;
|
||||
border-bottom: 1px dashed var(--cui-border-color);
|
||||
}
|
||||
|
||||
.brdvx-root .mobile-field:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.brdvx-root .field-label {
|
||||
font-size: var(--brdvx-font-size-label);
|
||||
color: var(--cui-secondary-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.25px;
|
||||
font-weight: var(--brdvx-font-weight-heading);
|
||||
}
|
||||
|
||||
.brdvx-root .field-value {
|
||||
font-size: var(--brdvx-font-size);
|
||||
color: var(--cui-body-color);
|
||||
font-weight: var(--brdvx-font-weight-normal);
|
||||
word-break: break-word;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.brdvx-root .mobile-actions {
|
||||
margin-top: auto;
|
||||
padding-top: 0.55rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-wrapper {
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
// background: #fff;
|
||||
// border: 1px solid #e9ecef;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-head {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 0.55rem 0.75rem;
|
||||
padding: 0 0.65rem 0.35rem;
|
||||
border-bottom: 1px solid var(--cui-border-color);
|
||||
margin-bottom: 0.2rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-head-cell {
|
||||
font-size: var(--brdvx-font-size-heading);
|
||||
color: var(--cui-secondary-color);
|
||||
font-weight: var(--brdvx-font-weight-heading);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-card {
|
||||
border: 1px solid var(--cui-border-color);
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-card-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-main {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.55rem 0.75rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-main.with-image {
|
||||
grid-template-columns: 15% 85%;
|
||||
align-items: start;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-image-col {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-mobile-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 0.55rem;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--cui-border-color);
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-data-col {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 0.55rem 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brdvx-root .desktop-row-grid {
|
||||
grid-template-columns: repeat(var(--brdvx-desktop-cols, 6), minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-cell {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-value {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: var(--brdvx-font-size);
|
||||
color: var(--cui-body-color);
|
||||
font-weight: var(--brdvx-font-weight-normal);
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.brdvx-sort-button {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.35rem;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.brdvx-sort-button:hover {
|
||||
color: var(--cui-primary);
|
||||
}
|
||||
|
||||
.brdvx-root .row-pair {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.brdvx-root .row-left {
|
||||
color: var(--cui-secondary-color);
|
||||
font-weight: var(--brdvx-font-weight-heading);
|
||||
font-size: var(--brdvx-font-size-label);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.brdvx-root .row-left.no-label {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.brdvx-root .row-right {
|
||||
flex: 1 1 100%;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.brdvx-root .row-right.no-label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
margin-left: auto;
|
||||
width: var(--brdvx-action-col-width, 230px);
|
||||
min-width: var(--brdvx-action-col-width, 230px);
|
||||
}
|
||||
|
||||
.brdvx-root .table-avatar {
|
||||
width: 55px;
|
||||
height: 55px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.brdvx-root .table-actions {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-hidden {
|
||||
border-top: 1px dashed var(--cui-border-color);
|
||||
padding: 0.55rem 0.65rem 0.65rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.brdvx-root .hidden-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(80px, 1fr) minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.brdvx-root .hidden-label {
|
||||
font-size: var(--brdvx-font-size-label);
|
||||
color: var(--cui-secondary-color);
|
||||
font-weight: var(--brdvx-font-weight-heading);
|
||||
}
|
||||
|
||||
.brdvx-root .hidden-value {
|
||||
font-size: var(--brdvx-font-size);
|
||||
color: var(--cui-body-color);
|
||||
font-weight: var(--brdvx-font-weight-normal);
|
||||
}
|
||||
|
||||
.brdvx-root .pagination-center {
|
||||
margin-top: 0.9rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.brdvx-root .cards-grid {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.brdvx-root .cards-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.brdvx-root .data-toolbar {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brdvx-root .toolbar-left {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brdvx-root .toolbar-search {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-card-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-data-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-data-col.mobile-split {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.brdvx-root .mobile-col {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.brdvx-root .row-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.brdvx-root .row-right {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.brdvx-root .table-list-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.brdvx-root .table-actions {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.brdvx-root .cards-grid.mobile {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.brdvx-root .mobile-card {
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
|
||||
.brdvx-root .mobile-card-body {
|
||||
padding: 0.55rem 0.6rem;
|
||||
}
|
||||
|
||||
.brdvx-root .field-label {
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.brdvx-root .field-value {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
background-color: var(--#{$prefix}tertiary-bg);
|
||||
background-color: rgba(var(--#{$prefix}tertiary-bg-rgb), .75);
|
||||
}
|
||||
|
||||
& + p {
|
||||
@@ -106,9 +106,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include color-mode(dark) {
|
||||
.example .tab-content {
|
||||
background-color: var(--#{$prefix}secondary-bg);
|
||||
}
|
||||
}
|
||||
|
||||
+262
-6
@@ -1,18 +1,30 @@
|
||||
@use "@coreui/coreui/scss/coreui" as * with (
|
||||
$enable-deprecation-messages: false
|
||||
@use '@coreui/coreui/scss/coreui' as * with (
|
||||
$enable-deprecation-messages: false,
|
||||
$primary: #f28500,
|
||||
$primary-text-emphasis: #9e5500,
|
||||
$primary-bg-subtle: #fde6cc,
|
||||
$primary-border-subtle: #f9b364
|
||||
);
|
||||
@use "@coreui/chartjs/scss/coreui-chartjs";
|
||||
@use "vendors/simplebar";
|
||||
@use '@coreui/chartjs/scss/coreui-chartjs';
|
||||
@use 'vendors/simplebar';
|
||||
@use 'base-responsive-data-view';
|
||||
|
||||
body {
|
||||
background-color: var(--cui-tertiary-bg);
|
||||
}
|
||||
|
||||
:root {
|
||||
--cui-primary: #f28500;
|
||||
--cui-primary-rgb: 242, 133, 0;
|
||||
--cui-link-color: #f28500;
|
||||
--cui-link-color-rgb: 242, 133, 0;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
padding-inline: var(--cui-sidebar-occupy-start, 0) var(--cui-sidebar-occupy-end, 0);
|
||||
will-change: auto;
|
||||
@include transition(padding .15s);
|
||||
@include transition(padding 0.15s);
|
||||
}
|
||||
|
||||
.header > .container-fluid,
|
||||
@@ -48,6 +60,37 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link {
|
||||
&.active {
|
||||
color: #ff8c00;
|
||||
background: rgba(255, 140, 0, 0.1);
|
||||
|
||||
.nav-icon {
|
||||
color: #ff8c00;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #ff8c00;
|
||||
background: rgba(255, 140, 0, 0.08);
|
||||
|
||||
.nav-icon {
|
||||
color: #ff8c00;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-nav .nav-link {
|
||||
&.active {
|
||||
color: #ff8c00;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: #ff8c00;
|
||||
}
|
||||
}
|
||||
|
||||
.header > .container-fluid + .container-fluid {
|
||||
min-height: 3rem;
|
||||
}
|
||||
@@ -57,11 +100,224 @@ body {
|
||||
}
|
||||
|
||||
@include color-mode(dark) {
|
||||
--cui-body-bg: #0d0e12;
|
||||
--cui-body-bg-rgb: 13, 14, 18;
|
||||
--cui-secondary-bg: #121318;
|
||||
--cui-secondary-bg-rgb: 18, 19, 24;
|
||||
--cui-tertiary-bg: #16171d;
|
||||
--cui-tertiary-bg-rgb: 22, 23, 29;
|
||||
--cui-sidebar-bg: #0d0e12;
|
||||
--cui-sidebar-nav-link-active-bg: rgba(242, 133, 0, 0.1);
|
||||
--cui-card-bg: #16171d;
|
||||
--cui-card-cap-bg: #16171d;
|
||||
--cui-card-border-color: #22242d;
|
||||
--cui-border-color: #22242d;
|
||||
--cui-border-color-translucent: rgba(34, 36, 45, 0.8);
|
||||
--cui-dark-bg-subtle: #0d0e12;
|
||||
--cui-primary: #f28500;
|
||||
--cui-primary-rgb: 242, 133, 0;
|
||||
--cui-link-color: #f28500;
|
||||
--cui-link-color-rgb: 242, 133, 0;
|
||||
--cui-table-bg: #16171d;
|
||||
--cui-table-color: rgba(255, 255, 255, 0.87);
|
||||
--cui-input-bg: #16171d;
|
||||
--cui-input-color: rgba(255, 255, 255, 0.87);
|
||||
--cui-input-border-color: #22242d;
|
||||
--cui-list-group-bg: #16171d;
|
||||
--cui-list-group-border-color: #22242d;
|
||||
|
||||
body {
|
||||
background-color: var(--cui-dark-bg-subtle);
|
||||
background-color: var(--cui-body-bg);
|
||||
}
|
||||
|
||||
.footer {
|
||||
--cui-footer-bg: var(--cui-body-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-dark {
|
||||
--cui-body-bg: #0d0e12;
|
||||
--cui-secondary-bg: #121318;
|
||||
--cui-tertiary-bg: #16171d;
|
||||
--cui-border-color: #22242d;
|
||||
}
|
||||
|
||||
// font bold untuk toast edit
|
||||
.swal-title-bold {
|
||||
font-weight: 700 !important; /* Bold */
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
//materials
|
||||
.timeline {
|
||||
position: relative;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #d8dbe0;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.timeline-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 2px solid #d8dbe0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
margin-left: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.table tbody tr {
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
transform: scale(1.01);
|
||||
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
// Tambahkan class ini pada <img> atau pembungkus gambar agar dapat diklik dan diperbesar.
|
||||
.image-previewable {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.image-previewable img {
|
||||
transition: transform 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
|
||||
.image-previewable:hover img {
|
||||
filter: brightness(0.92);
|
||||
transform: scale(1.025);
|
||||
}
|
||||
|
||||
body.image-preview-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-preview-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 11000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: clamp(1rem, 4vw, 3rem);
|
||||
background: rgba(7, 10, 16, 0.88);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.image-preview-modal-image {
|
||||
display: block;
|
||||
max-width: min(92vw, 1100px);
|
||||
max-height: 88vh;
|
||||
object-fit: contain;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.image-preview-close {
|
||||
position: fixed;
|
||||
top: 1.25rem;
|
||||
right: 1.25rem;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.image-preview-fade-enter-active,
|
||||
.image-preview-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.image-preview-fade-enter-from,
|
||||
.image-preview-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.header-profile-avatar {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-photo-avatar {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
font-size: 2rem;
|
||||
border: 4px solid var(--cui-body-bg);
|
||||
box-shadow: 0 0 0 2px rgba(var(--cui-primary-rgb), 0.25), 0 0.75rem 2rem rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.profile-photo-uploader {
|
||||
padding: 1.15rem;
|
||||
background: rgba(var(--cui-primary-rgb), 0.045);
|
||||
border: 1.5px dashed rgba(var(--cui-primary-rgb), 0.42);
|
||||
border-radius: 1rem;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.profile-photo-uploader.is-dragging {
|
||||
background: rgba(var(--cui-primary-rgb), 0.12);
|
||||
border-color: var(--cui-primary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.profile-photo-uploader-icon {
|
||||
display: grid;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin: 0 auto 0.7rem;
|
||||
color: var(--cui-primary);
|
||||
background: rgba(var(--cui-primary-rgb), 0.12);
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.profile-photo-file {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.profile-photo-file span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
const MAX_CUSTOMER_IMAGE_BYTES = 500 * 1024
|
||||
|
||||
function canvasBlob(canvas, type, quality) {
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, type, quality))
|
||||
}
|
||||
|
||||
export async function compressCustomerImage(file) {
|
||||
if (!file.type.startsWith('image/')) throw new Error('File harus berupa gambar.')
|
||||
|
||||
const bitmap = await createImageBitmap(file)
|
||||
const maxDimension = 1920
|
||||
let scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height))
|
||||
let width = Math.max(1, Math.round(bitmap.width * scale))
|
||||
let height = Math.max(1, Math.round(bitmap.height * scale))
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d', { alpha: false })
|
||||
let blob = null
|
||||
let quality = 0.86
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
context.fillStyle = '#ffffff'
|
||||
context.fillRect(0, 0, width, height)
|
||||
context.drawImage(bitmap, 0, 0, width, height)
|
||||
blob = await canvasBlob(canvas, 'image/webp', quality)
|
||||
|
||||
if (blob && blob.size <= MAX_CUSTOMER_IMAGE_BYTES) break
|
||||
if (quality > 0.58) quality -= 0.08
|
||||
else {
|
||||
width = Math.max(640, Math.round(width * 0.82))
|
||||
height = Math.max(360, Math.round(height * 0.82))
|
||||
}
|
||||
}
|
||||
|
||||
bitmap.close()
|
||||
if (!blob || blob.size > MAX_CUSTOMER_IMAGE_BYTES) {
|
||||
throw new Error('Gambar tidak dapat dikompresi hingga di bawah 500 KB.')
|
||||
}
|
||||
|
||||
const baseName = file.name.replace(/\.[^.]+$/, '')
|
||||
return new File([blob], `${baseName}.webp`, {
|
||||
type: 'image/webp',
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export { MAX_CUSTOMER_IMAGE_BYTES }
|
||||
@@ -0,0 +1,18 @@
|
||||
export const RESPONSIVE_VIEW_MODE_STORAGE_KEY = 'manja-responsive-data-view-mode'
|
||||
export const RESPONSIVE_VIEW_MODE_EVENT = 'manja-responsive-data-view-mode-change'
|
||||
|
||||
export const getResponsiveViewMode = () => {
|
||||
const storedMode = localStorage.getItem(RESPONSIVE_VIEW_MODE_STORAGE_KEY)
|
||||
return storedMode === 'card' || storedMode === 'table' ? storedMode : 'table'
|
||||
}
|
||||
|
||||
export const saveResponsiveViewMode = (mode) => {
|
||||
if (mode !== 'card' && mode !== 'table') return
|
||||
|
||||
localStorage.setItem(RESPONSIVE_VIEW_MODE_STORAGE_KEY, mode)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(RESPONSIVE_VIEW_MODE_EVENT, {
|
||||
detail: mode,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
const TOKEN_KEY = 'UU7HVn7ECHe7MvqMTeTeEpoFdBA2wc0NHQX29WyG91fc6982'
|
||||
const USER_KEY = 'manja_authenticated_user'
|
||||
|
||||
export function setToken(token) {
|
||||
sessionStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return sessionStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function removeToken() {
|
||||
sessionStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function hasToken() {
|
||||
return !!sessionStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setAuthenticatedUser(user) {
|
||||
if (user) {
|
||||
sessionStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthenticatedUser() {
|
||||
const value = sessionStorage.getItem(USER_KEY)
|
||||
|
||||
if (!value) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
sessionStorage.removeItem(USER_KEY)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function removeAuthenticatedUser() {
|
||||
sessionStorage.removeItem(USER_KEY)
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
sessionStorage.clear()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Swal from 'sweetalert2'
|
||||
|
||||
export function showConfirm(title = 'Konfirmasi', text = 'Apakah Anda yakin?') {
|
||||
return Swal.fire({
|
||||
title,
|
||||
text,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#dc3545',
|
||||
confirmButtonText: 'Ya, hapus!',
|
||||
cancelButtonText: 'Batal',
|
||||
theme: 'auto',
|
||||
})
|
||||
}
|
||||
|
||||
export function showSuccess(message = 'Data berhasil disimpan') {
|
||||
Swal.fire({
|
||||
position: 'top-end',
|
||||
icon: 'success',
|
||||
// title: 'Berhasil',
|
||||
text: message,
|
||||
showConfirmButton: false,
|
||||
timer: 2000,
|
||||
theme: 'auto',
|
||||
toast: true,
|
||||
width: '350px',
|
||||
padding: '10px 18px',
|
||||
customClass: {
|
||||
title: 'swal-title-bold',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function showError(message = 'Terjadi Kesalahan') {
|
||||
Swal.fire({
|
||||
position: 'top-end',
|
||||
icon: 'error',
|
||||
// title: 'Gagal',
|
||||
text: message,
|
||||
showConfirmButton: 'Tutup',
|
||||
// timer: 2000,
|
||||
theme: 'auto',
|
||||
toast: true,
|
||||
width: '350px',
|
||||
padding: '10px 18px',
|
||||
customClass: {
|
||||
title: 'swal-title-bold',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export function formatTanggal(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
if (isNaN(d.getTime())) return dateStr
|
||||
const months = [
|
||||
'Januari',
|
||||
'Februari',
|
||||
'Maret',
|
||||
'April',
|
||||
'Mei',
|
||||
'Juni',
|
||||
'Juli',
|
||||
'Agustus',
|
||||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Desember',
|
||||
]
|
||||
const day = d.getDate()
|
||||
const month = months[d.getMonth()]
|
||||
const year = d.getFullYear()
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0')
|
||||
return `${day} ${month} ${year} (${hours}:${minutes}:${seconds})`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
const VERIFICATION_SESSION_KEY = 'manja_verification_context'
|
||||
|
||||
export function setVerificationContext(payload) {
|
||||
sessionStorage.setItem(VERIFICATION_SESSION_KEY, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
export function getVerificationContext() {
|
||||
const raw = sessionStorage.getItem(VERIFICATION_SESSION_KEY)
|
||||
if (!raw) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse verification session context:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function clearVerificationContext() {
|
||||
sessionStorage.removeItem(VERIFICATION_SESSION_KEY)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import {
|
||||
getRoleApplications,
|
||||
getStaffMenuGroupOptions,
|
||||
getTenantMenuGroupOptions,
|
||||
reviewRoleApplication,
|
||||
} from '@/services/roleApplicationService'
|
||||
import { showError, showSuccess } from '@/utils/swal'
|
||||
|
||||
const route = useRoute(),
|
||||
type = computed(() => route.meta.applicationType),
|
||||
loading = ref(true),
|
||||
loaded = ref(false),
|
||||
saving = ref(false),
|
||||
menuGroupsLoading = ref(false),
|
||||
menuGroups = ref([]),
|
||||
items = ref([]),
|
||||
modal = ref(false),
|
||||
selected = ref(null)
|
||||
const query = ref({ page: 1, per_page: 10, status: 'pending' }),
|
||||
pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
|
||||
const form = reactive({
|
||||
decision: 'approved',
|
||||
review_notes: '',
|
||||
menu_group_id: '',
|
||||
payment_type: 'prepaid',
|
||||
price_mode: 'discount',
|
||||
discount_percent: 0,
|
||||
commission_percent: 0,
|
||||
credit_limit: 0,
|
||||
})
|
||||
const config = computed(
|
||||
() =>
|
||||
({
|
||||
tenant: ['Pengajuan Tenant', 'Persetujuan tenant baru oleh platform'],
|
||||
agent: ['Pengajuan Agen Voucher', 'Persetujuan agen pada tenant aktif'],
|
||||
staff: ['Pengajuan Staff', 'Persetujuan calon karyawan pada tenant aktif'],
|
||||
})[type.value],
|
||||
)
|
||||
const columns = [
|
||||
{ key: 'applicant_label', label: 'Pemohon' },
|
||||
{ key: 'target_label', label: 'Pengajuan' },
|
||||
{ key: 'reason', label: 'Alasan' },
|
||||
{ key: 'status_label', label: 'Status' },
|
||||
{ key: 'submitted_label', label: 'Diajukan' },
|
||||
]
|
||||
const actions = [{ key: 'review', label: 'Tinjau', color: 'primary' }]
|
||||
async function fetchData(params = query.value) {
|
||||
query.value = { ...query.value, ...params }
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await getRoleApplications(type.value, query.value),
|
||||
rows = r?.data?.data || []
|
||||
items.value = rows.map((x) => ({
|
||||
...x,
|
||||
applicant_label: `${x.applicant?.name || '-'} · ${x.applicant?.email || ''}`,
|
||||
target_label: type.value === 'tenant' ? x.payload?.tenant_name : x.tenant?.tenant_name,
|
||||
reason: x.payload?.reason,
|
||||
status_label: { pending: 'Menunggu', approved: 'Disetujui', rejected: 'Ditolak' }[x.status],
|
||||
submitted_label: new Date(x.submitted_at).toLocaleString('id-ID'),
|
||||
}))
|
||||
pagination.value = {
|
||||
current_page: r?.data?.current_page || 1,
|
||||
last_page: r?.data?.last_page || 1,
|
||||
per_page: r?.data?.per_page || 10,
|
||||
total: r?.data?.total || 0,
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e?.response?.data?.message || 'Gagal memuat pengajuan.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
async function loadMenuGroups() {
|
||||
if (!['tenant', 'staff'].includes(type.value)) return
|
||||
menuGroupsLoading.value = true
|
||||
try {
|
||||
const r =
|
||||
type.value === 'tenant' ? await getTenantMenuGroupOptions() : await getStaffMenuGroupOptions()
|
||||
menuGroups.value = r?.data || []
|
||||
} catch (e) {
|
||||
showError(e?.response?.data?.message || 'Gagal memuat pilihan Group Menu.')
|
||||
} finally {
|
||||
menuGroupsLoading.value = false
|
||||
}
|
||||
}
|
||||
async function open({ item }) {
|
||||
selected.value = item
|
||||
Object.assign(form, {
|
||||
decision: 'approved',
|
||||
review_notes: '',
|
||||
menu_group_id: '',
|
||||
payment_type: 'prepaid',
|
||||
price_mode: 'discount',
|
||||
discount_percent: 0,
|
||||
commission_percent: 0,
|
||||
credit_limit: 0,
|
||||
})
|
||||
modal.value = true
|
||||
await loadMenuGroups()
|
||||
}
|
||||
async function submit() {
|
||||
if (
|
||||
['tenant', 'staff'].includes(type.value) &&
|
||||
form.decision === 'approved' &&
|
||||
!form.menu_group_id
|
||||
) {
|
||||
showError('Group Menu wajib dipilih sebelum menyetujui pengajuan.')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await reviewRoleApplication(selected.value.id, {
|
||||
...form,
|
||||
menu_group_id: form.menu_group_id || null,
|
||||
})
|
||||
modal.value = false
|
||||
showSuccess(`Pengajuan berhasil ${form.decision === 'approved' ? 'disetujui' : 'ditolak'}.`)
|
||||
await fetchData()
|
||||
} catch (e) {
|
||||
const errors = e?.response?.data?.errors
|
||||
showError(
|
||||
errors?.menu_group_id?.[0] || e?.response?.data?.message || 'Pengajuan gagal ditinjau.',
|
||||
)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
watch(type, () => {
|
||||
loaded.value = false
|
||||
menuGroups.value = []
|
||||
fetchData()
|
||||
})
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
<template>
|
||||
<div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header">
|
||||
<h5 class="mb-0 fw-semibold">{{ config[0] }}</h5>
|
||||
<small class="text-body-secondary">{{ config[1] }}</small>
|
||||
</div>
|
||||
<div v-if="loading && !loaded" class="brdvx-loading-state">
|
||||
<CSpinner color="primary" />
|
||||
<p class="brdvx-loading-text">Memuat data...</p>
|
||||
</div>
|
||||
<BaseResponsiveDataView
|
||||
v-else
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:actions="actions"
|
||||
server-side
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
@query-change="fetchData"
|
||||
@action-click="open"
|
||||
/>
|
||||
</div>
|
||||
<CModal :visible="modal" @close="modal = false"
|
||||
><CModalHeader><CModalTitle>Tinjau Pengajuan</CModalTitle></CModalHeader
|
||||
><CModalBody
|
||||
><CAlert color="info"
|
||||
><strong>{{ selected?.applicant?.name }}</strong>
|
||||
<div>{{ selected?.reason }}</div></CAlert
|
||||
><CFormLabel>Keputusan</CFormLabel
|
||||
><CFormSelect v-model="form.decision" class="mb-3"
|
||||
><option value="approved">Setujui</option>
|
||||
<option value="rejected">Tolak</option></CFormSelect
|
||||
><template v-if="['tenant', 'staff'].includes(type) && form.decision === 'approved'"
|
||||
><CFormLabel>Group Menu <span class="text-danger">*</span></CFormLabel
|
||||
><CFormSelect
|
||||
v-model="form.menu_group_id"
|
||||
class="mb-2"
|
||||
:disabled="menuGroupsLoading"
|
||||
required
|
||||
><option value="">
|
||||
{{ menuGroupsLoading ? 'Memuat Group Menu...' : `Pilih Group Menu ${type}` }}
|
||||
</option>
|
||||
<option v-for="group in menuGroups" :key="group.id" :value="group.id">
|
||||
{{ group.name }} ({{ group.menus_count }} menu)
|
||||
</option></CFormSelect
|
||||
><small v-if="form.menu_group_id" class="d-block text-body-secondary mb-3">{{
|
||||
menuGroups.find((group) => group.id === Number(form.menu_group_id))?.description
|
||||
}}</small
|
||||
><CAlert v-if="!menuGroupsLoading && !menuGroups.length" color="warning"
|
||||
>Belum ada Group Menu yang dapat diberikan.</CAlert
|
||||
></template
|
||||
><template v-if="type === 'agent' && form.decision === 'approved'"
|
||||
><CFormLabel>Kategori Agen</CFormLabel
|
||||
><CFormSelect v-model="form.payment_type" class="mb-3"
|
||||
><option value="prepaid">Deposit dahulu</option>
|
||||
<option value="postpaid">Jual dahulu, bayar kemudian</option></CFormSelect
|
||||
><CFormLabel>Skema Harga</CFormLabel
|
||||
><CFormSelect v-model="form.price_mode" class="mb-3"
|
||||
><option value="discount">Diskon</option>
|
||||
<option value="commission">Komisi</option></CFormSelect
|
||||
><CRow class="g-2"
|
||||
><CCol :md="6"
|
||||
><CFormLabel>Diskon (%)</CFormLabel
|
||||
><CFormInput v-model.number="form.discount_percent" type="number" /></CCol
|
||||
><CCol :md="6"
|
||||
><CFormLabel>Komisi (%)</CFormLabel
|
||||
><CFormInput v-model.number="form.commission_percent" type="number" /></CCol
|
||||
><CCol v-if="form.payment_type === 'postpaid'" :xs="12"
|
||||
><CFormLabel>Limit Kredit</CFormLabel
|
||||
><CFormInput v-model.number="form.credit_limit" type="number" /></CCol></CRow></template
|
||||
><CFormLabel class="mt-3">Catatan Reviewer</CFormLabel
|
||||
><CFormTextarea v-model="form.review_notes" rows="3" /></CModalBody
|
||||
><CModalFooter
|
||||
><CButton color="secondary" @click="modal = false">Batal</CButton
|
||||
><CButton
|
||||
:color="form.decision === 'approved' ? 'success' : 'danger'"
|
||||
:disabled="
|
||||
saving ||
|
||||
(['tenant', 'staff'].includes(type) &&
|
||||
form.decision === 'approved' &&
|
||||
(!form.menu_group_id || menuGroupsLoading))
|
||||
"
|
||||
@click="submit"
|
||||
>Simpan Keputusan</CButton
|
||||
></CModalFooter
|
||||
></CModal
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,303 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { createRoleApplication, getMyRoleApplications } from '@/services/roleApplicationService'
|
||||
import { showError, showSuccess } from '@/utils/swal'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(true),
|
||||
saving = ref(false),
|
||||
items = ref([]),
|
||||
modal = ref('')
|
||||
const form = reactive({})
|
||||
const statusColor = { pending: 'warning', approved: 'success', rejected: 'danger' }
|
||||
const accessLevel = computed(() => authStore.user?.access_level || 'customer')
|
||||
const pendingTypes = computed(
|
||||
() =>
|
||||
new Set(
|
||||
items.value.filter((item) => item.status === 'pending').map((item) => item.application_type),
|
||||
),
|
||||
)
|
||||
const approvedTypes = computed(
|
||||
() =>
|
||||
new Set(
|
||||
items.value.filter((item) => item.status === 'approved').map((item) => item.application_type),
|
||||
),
|
||||
)
|
||||
const isTenantOwner = computed(
|
||||
() => accessLevel.value === 'tenant_owner' || approvedTypes.value.has('tenant'),
|
||||
)
|
||||
const isStaff = computed(() => accessLevel.value === 'staff' || approvedTypes.value.has('staff'))
|
||||
const hasPendingTenant = computed(() => pendingTypes.value.has('tenant'))
|
||||
const hasPendingStaff = computed(() => pendingTypes.value.has('staff'))
|
||||
const hasPendingAgent = computed(() => pendingTypes.value.has('agent'))
|
||||
const showTenantAction = computed(
|
||||
() => !isTenantOwner.value && !isStaff.value && !hasPendingTenant.value && !hasPendingStaff.value,
|
||||
)
|
||||
const showStaffAction = computed(
|
||||
() => !isTenantOwner.value && !hasPendingTenant.value && !hasPendingStaff.value,
|
||||
)
|
||||
const showAgentAction = computed(
|
||||
() => !isTenantOwner.value && !hasPendingTenant.value && !hasPendingAgent.value,
|
||||
)
|
||||
const contextMessage = computed(() => {
|
||||
if (isTenantOwner.value)
|
||||
return 'Akun Anda sudah menjadi Tenant Owner. Pengajuan Staff dan Agen tidak tersedia.'
|
||||
if (hasPendingTenant.value)
|
||||
return 'Pengajuan Tenant Owner sedang ditinjau. Pengajuan lain ditutup sementara sampai proses selesai.'
|
||||
if (isStaff.value)
|
||||
return 'Sebagai staff, Anda dapat mengajukan perubahan akses staff pada tenant atau menjadi agen.'
|
||||
if (hasPendingStaff.value)
|
||||
return 'Pengajuan Staff sedang ditinjau. Anda tetap dapat mengajukan diri sebagai agen.'
|
||||
return 'Pilih jenis akses yang ingin Anda ajukan.'
|
||||
})
|
||||
const defaults = (type) =>
|
||||
type === 'tenant'
|
||||
? {
|
||||
application_type: 'tenant',
|
||||
tenant_name: '',
|
||||
tenant_code: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
reason: '',
|
||||
}
|
||||
: { application_type: type, tenant_id: '', reason: '' }
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = (await getMyRoleApplications())?.data || []
|
||||
} catch (e) {
|
||||
showError(e?.response?.data?.message || 'Gagal memuat pengajuan.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function open(type) {
|
||||
Object.keys(form).forEach((key) => delete form[key])
|
||||
Object.assign(form, defaults(type))
|
||||
modal.value = type
|
||||
}
|
||||
async function submit() {
|
||||
saving.value = true
|
||||
try {
|
||||
await createRoleApplication(form)
|
||||
modal.value = ''
|
||||
showSuccess('Pengajuan berhasil dikirim dan menunggu persetujuan.')
|
||||
await load()
|
||||
} catch (e) {
|
||||
showError(e?.response?.data?.message || 'Pengajuan gagal dikirim.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header">
|
||||
<h5 class="mb-0 fw-semibold">Pengajuan Saya</h5>
|
||||
<small class="text-body-secondary"
|
||||
>Akun baru selalu berstatus customer. Ajukan akses tenant, staff, atau agen sesuai
|
||||
kebutuhan.</small
|
||||
>
|
||||
</div>
|
||||
<CAlert v-if="!loading" color="info" class="application-context">
|
||||
<CIcon icon="cil-info" />
|
||||
<span>{{ contextMessage }}</span>
|
||||
</CAlert>
|
||||
<div v-if="!loading" class="application-actions">
|
||||
<button v-if="showTenantAction" @click="open('tenant')">
|
||||
<span><CIcon icon="cil-building" size="xl" /></span>
|
||||
<div>
|
||||
<strong>Menjadi Pemilik Tenant</strong
|
||||
><small>Buat dan kelola layanan jaringan Anda sendiri</small>
|
||||
</div></button
|
||||
><button v-if="showAgentAction" @click="open('agent')">
|
||||
<span><CIcon icon="cil-user-follow" size="xl" /></span>
|
||||
<div>
|
||||
<strong>Menjadi Agen Voucher</strong
|
||||
><small>Bergabung sebagai agen pada tenant tertentu</small>
|
||||
</div>
|
||||
</button>
|
||||
<button v-if="showStaffAction" @click="open('staff')">
|
||||
<span><CIcon icon="cil-people" size="xl" /></span>
|
||||
<div>
|
||||
<strong>Menjadi Staff Tenant</strong
|
||||
><small>Bergabung sebagai karyawan pada tenant tertentu</small>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
v-if="!showTenantAction && !showStaffAction && !showAgentAction"
|
||||
class="application-actions-empty"
|
||||
>
|
||||
<CIcon :icon="hasPendingTenant ? 'cil-task' : 'cil-check-circle'" size="xl" />
|
||||
<strong>{{ hasPendingTenant ? 'Menunggu persetujuan' : 'Akses sudah aktif' }}</strong>
|
||||
<small>{{ contextMessage }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="brdvx-loading-state">
|
||||
<CSpinner color="primary" />
|
||||
<p class="brdvx-loading-text">Memuat data...</p>
|
||||
</div>
|
||||
<div v-else class="history">
|
||||
<h6>Riwayat Pengajuan</h6>
|
||||
<div v-if="!items.length" class="empty">Belum ada pengajuan.</div>
|
||||
<article v-for="item in items" :key="item.id">
|
||||
<span class="type-icon"
|
||||
><CIcon
|
||||
:icon="
|
||||
item.application_type === 'tenant'
|
||||
? 'cil-building'
|
||||
: item.application_type === 'staff'
|
||||
? 'cil-people'
|
||||
: 'cil-user-follow'
|
||||
"
|
||||
/></span>
|
||||
<div>
|
||||
<strong>{{
|
||||
item.application_type === 'tenant'
|
||||
? item.payload?.tenant_name
|
||||
: `${item.application_type === 'staff' ? 'Staff' : 'Agen'} ${item.tenant?.tenant_name || `Tenant #${item.tenant_id}`}`
|
||||
}}</strong
|
||||
><small>Diajukan {{ new Date(item.submitted_at).toLocaleString('id-ID') }}</small>
|
||||
<p v-if="item.review_notes">Catatan: {{ item.review_notes }}</p>
|
||||
</div>
|
||||
<CBadge :color="statusColor[item.status]">{{
|
||||
{ pending: 'Menunggu', approved: 'Disetujui', rejected: 'Ditolak' }[item.status]
|
||||
}}</CBadge>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<CModal :visible="Boolean(modal)" @close="modal = ''"
|
||||
><CModalHeader
|
||||
><CModalTitle>{{
|
||||
modal === 'tenant'
|
||||
? 'Pengajuan Tenant'
|
||||
: modal === 'staff'
|
||||
? 'Pengajuan Staff Tenant'
|
||||
: 'Pengajuan Agen Voucher'
|
||||
}}</CModalTitle></CModalHeader
|
||||
><CModalBody
|
||||
><template v-if="modal === 'tenant'"
|
||||
><CFormLabel>Nama Tenant/Usaha</CFormLabel
|
||||
><CFormInput v-model="form.tenant_name" class="mb-3" /><CFormLabel
|
||||
>Kode Tenant yang Diinginkan</CFormLabel
|
||||
><CFormInput v-model="form.tenant_code" placeholder="Opsional" class="mb-3" /><CFormLabel
|
||||
>Telepon/WhatsApp Usaha</CFormLabel
|
||||
><CFormInput v-model="form.phone" class="mb-3" /><CFormLabel>Email Usaha</CFormLabel
|
||||
><CFormInput v-model="form.email" type="email" class="mb-3" /><CFormLabel
|
||||
>Alamat Usaha</CFormLabel
|
||||
><CFormTextarea v-model="form.address" rows="2" class="mb-3" /></template
|
||||
><template v-else
|
||||
><CAlert color="info">Masukkan ID tenant yang diberikan oleh pemilik tenant.</CAlert
|
||||
><CFormLabel>ID Tenant</CFormLabel
|
||||
><CFormInput v-model.number="form.tenant_id" type="number" min="1" class="mb-3" /></template
|
||||
><CFormLabel>Alasan Pengajuan</CFormLabel
|
||||
><CFormTextarea v-model="form.reason" rows="3" /></CModalBody
|
||||
><CModalFooter
|
||||
><CButton color="secondary" @click="modal = ''">Batal</CButton
|
||||
><CButton color="primary" :disabled="saving" @click="submit"
|
||||
>Kirim Pengajuan</CButton
|
||||
></CModalFooter
|
||||
></CModal
|
||||
>
|
||||
</template>
|
||||
<style scoped>
|
||||
.application-context {
|
||||
display: flex;
|
||||
max-width: 850px;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin: 1rem auto 0;
|
||||
}
|
||||
.application-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 340px));
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0 2rem;
|
||||
}
|
||||
.application-actions-empty {
|
||||
display: flex;
|
||||
width: min(100%, 420px);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 1.4rem;
|
||||
color: var(--cui-secondary-color);
|
||||
text-align: center;
|
||||
background: var(--cui-tertiary-bg);
|
||||
border: 1px dashed var(--cui-border-color);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
.application-actions-empty strong {
|
||||
color: var(--cui-body-color);
|
||||
}
|
||||
.application-actions button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
color: var(--cui-body-color);
|
||||
text-align: left;
|
||||
background: var(--cui-body-bg);
|
||||
border: 1px solid var(--cui-border-color);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 0.4rem 1rem rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.application-actions button:hover {
|
||||
border-color: #ff8c00;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.application-actions span,
|
||||
.type-icon {
|
||||
display: grid;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 48px;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #f97316, #ff8c00);
|
||||
border-radius: 0.8rem;
|
||||
}
|
||||
.application-actions div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.application-actions small,
|
||||
.history article small {
|
||||
color: var(--cui-secondary-color);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.history {
|
||||
max-width: 850px;
|
||||
margin: auto;
|
||||
}
|
||||
.history > h6 {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.history article {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0.8rem 0;
|
||||
border-bottom: 1px solid var(--cui-border-color);
|
||||
}
|
||||
.history article > div {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.history article p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--cui-secondary-color);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.empty {
|
||||
padding: 2rem;
|
||||
color: var(--cui-secondary-color);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--cui-border-color);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<CRow>
|
||||
<CCol :xs="12">
|
||||
<DocsComponents href="components/chip/" />
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Vue Chip</strong> <small>Basic and outline</small>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<p class="text-body-secondary small">
|
||||
Chips are compact UI elements for labels, filters, and quick selections. Use the
|
||||
<code>CChip</code> component with optional <code>variant</code> prop.
|
||||
</p>
|
||||
<DocsExample href="components/chip/#basic-chips">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<CChip>Basic chip</CChip>
|
||||
<CChip>Frontend</CChip>
|
||||
<CChip>Design system</CChip>
|
||||
<CChip>Documentation</CChip>
|
||||
</div>
|
||||
</DocsExample>
|
||||
<p class="text-body-secondary small">
|
||||
Add <code>variant="outline"</code> to create a lighter, bordered appearance.
|
||||
</p>
|
||||
<DocsExample href="components/chip/#outline-chips">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<CChip variant="outline">Outline chip</CChip>
|
||||
<CChip variant="outline">Product</CChip>
|
||||
<CChip variant="outline">Marketing</CChip>
|
||||
<CChip variant="outline">Analytics</CChip>
|
||||
</div>
|
||||
</DocsExample>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Vue Chip</strong> <small>With icons and avatars</small>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<p class="text-body-secondary small">
|
||||
Chips can include icons, user avatars, or avatar components to make tags easier to scan.
|
||||
</p>
|
||||
<DocsExample href="components/chip/#chips-with-icons">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<CChip>
|
||||
<span class="chip-icon">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 1a3 3 0 1 1 0 6 3 3 0 0 1 0-6Zm-5 12s0-3 5-3 5 3 5 3v1H3v-1Z" />
|
||||
</svg>
|
||||
</span>
|
||||
Team member
|
||||
</CChip>
|
||||
<CChip>
|
||||
<span class="chip-icon">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M2 2h12v2H2V2Zm0 5h8v2H2V7Zm0 5h12v2H2v-2Z" />
|
||||
</svg>
|
||||
</span>
|
||||
Backlog item
|
||||
</CChip>
|
||||
</div>
|
||||
</DocsExample>
|
||||
<DocsExample href="components/chip/#chips-with-avatars">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<CChip>
|
||||
<img class="chip-img" :src="avatar1" width="16" height="16" alt="" /> Olivia Martin
|
||||
</CChip>
|
||||
<CChip>
|
||||
<img class="chip-img" :src="avatar4" width="16" height="16" alt="" /> Ethan Carter
|
||||
</CChip>
|
||||
<CChip>
|
||||
<span class="avatar avatar-sm bg-primary text-white">A</span> Account manager
|
||||
</CChip>
|
||||
<CChip>
|
||||
<span class="avatar avatar-sm bg-success text-white">Q</span> QA owner
|
||||
</CChip>
|
||||
</div>
|
||||
</DocsExample>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
|
||||
<CCol :xs="12">
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Vue Chip</strong> <small>Variants and sizes</small>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<p class="text-body-secondary small">
|
||||
Use the <code>color</code> prop for contextual variants and the <code>size</code> prop
|
||||
for different sizes.
|
||||
</p>
|
||||
<DocsExample href="components/chip/#variants">
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<CChip color="primary" clickable>Product</CChip>
|
||||
<CChip color="primary" active>Active product</CChip>
|
||||
<CChip color="success" clickable>Published</CChip>
|
||||
<CChip color="success" active>Live</CChip>
|
||||
<CChip color="warning" clickable>Review</CChip>
|
||||
<CChip color="danger" active>Blocked</CChip>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<CChip size="sm">Small chip</CChip>
|
||||
<CChip>Default chip</CChip>
|
||||
<CChip size="lg">Large chip</CChip>
|
||||
<CChip variant="outline" color="primary" clickable>Outline primary</CChip>
|
||||
</div>
|
||||
</DocsExample>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Vue Chip</strong> <small>Interactive examples</small>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<p class="text-body-secondary small">
|
||||
Chips support selection, removal, and disabled states using props like
|
||||
<code>selectable</code>, <code>removable</code>, and <code>disabled</code>.
|
||||
</p>
|
||||
<DocsExample href="components/chip/#interactive">
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<CChip selectable>Selectable</CChip>
|
||||
<CChip selectable selected>Selected</CChip>
|
||||
<CChip removable>Removable</CChip>
|
||||
<CChip disabled>Disabled</CChip>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<CChip size="lg" removable>Team Alpha</CChip>
|
||||
<CChip variant="outline" color="info" selectable>Filter: Priority</CChip>
|
||||
<CChip variant="outline" color="success" selectable selected>Filter: Ready</CChip>
|
||||
</div>
|
||||
</DocsExample>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
</CRow>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import avatar1 from '@/assets/images/avatars/1.jpg'
|
||||
import avatar4 from '@/assets/images/avatars/4.jpg'
|
||||
|
||||
export default {
|
||||
name: 'Chips',
|
||||
setup() {
|
||||
return {
|
||||
avatar1,
|
||||
avatar4,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getTenants } from '@/services/tenantService'
|
||||
import { createBillingProfile, deleteBillingProfile, getBillingProfiles, updateBillingProfile } from '@/services/billingService'
|
||||
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(true), loadedOnce = ref(false), saving = ref(false)
|
||||
const items = ref([]), tenants = ref([]), modal = ref(false), editingId = ref(null)
|
||||
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
|
||||
const query = ref({ page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' })
|
||||
const isMaster = computed(() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin')
|
||||
const form = reactive({})
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nama Profile' },
|
||||
{ key: 'type_label', label: 'Tipe Jadwal', sortable: false },
|
||||
{ key: 'schedule_label', label: 'Jadwal', sortable: false },
|
||||
{ key: 'tax_label', label: 'PPN', sortable: false },
|
||||
{ key: 'status_label', label: 'Status', sortKey: 'status' },
|
||||
]
|
||||
const actions = [
|
||||
{ key: 'edit', label: 'Edit', color: 'success' },
|
||||
{ key: 'delete', label: 'Hapus', color: 'danger' },
|
||||
]
|
||||
function defaults() {
|
||||
return {
|
||||
tenant_id: '', name: '', schedule_type: 'fixed_date',
|
||||
invoice_day: 1, send_day: 1, warning_day: 10, isolation_day: 15,
|
||||
invoice_days_before: 7, send_days_before: 5, warning_days_before: 0,
|
||||
isolation_days_after: 3, tax_type: 'non_tax', tax_rate: 0, status: 'active', notes: '',
|
||||
}
|
||||
}
|
||||
function normalize(item) {
|
||||
const taxes = { inclusive: 'Include PPN', exclusive: 'Exclude PPN', non_tax: 'Non PPN' }
|
||||
const installationSchedule = item.schedule_type === 'installation_date'
|
||||
return {
|
||||
...item,
|
||||
type_label: installationSchedule ? 'Tanggal pemasangan' : 'Tanggal tetap',
|
||||
schedule_label: installationSchedule
|
||||
? `Buat H-${item.invoice_days_before} · Kirim H-${item.send_days_before} · Peringatan ${Number(item.warning_days_before) === 0 ? 'H' : `H-${item.warning_days_before}`} · Isolir H+${item.isolation_days_after}`
|
||||
: `Buat ${item.invoice_day} · Kirim ${item.send_day} · Peringatan ${item.warning_day} · Isolir ${item.isolation_day}`,
|
||||
tax_label: `${taxes[item.tax_type] || item.tax_type}${item.tax_type !== 'non_tax' ? ` (${item.tax_rate}%)` : ''}`,
|
||||
status_label: item.status === 'active' ? 'Aktif' : 'Nonaktif',
|
||||
}
|
||||
}
|
||||
async function fetchData(params = query.value) {
|
||||
query.value = { ...query.value, ...params }; loading.value = true
|
||||
try {
|
||||
const response = await getBillingProfiles(query.value); const rows = response?.data?.data || []; items.value = rows.map(normalize)
|
||||
pagination.value = { current_page: response?.data?.current_page || 1, last_page: response?.data?.last_page || 1, per_page: response?.data?.per_page || 10, total: response?.data?.total || rows.length }
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Gagal memuat profile tagihan.') }
|
||||
finally { loading.value = false; loadedOnce.value = true }
|
||||
}
|
||||
async function openForm(item = null) {
|
||||
editingId.value = item?.id || null; Object.keys(form).forEach((key) => delete form[key]); Object.assign(form, defaults(), item || {})
|
||||
if (isMaster.value && !tenants.value.length) {
|
||||
const response = await getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' }); tenants.value = response?.data?.data || []
|
||||
}
|
||||
modal.value = true
|
||||
}
|
||||
async function save() {
|
||||
if (!form.name?.trim()) return showError('Nama profile wajib diisi.')
|
||||
if (isMaster.value && !form.tenant_id) return showError('Tenant wajib dipilih.')
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) await updateBillingProfile(editingId.value, form); else await createBillingProfile(form)
|
||||
modal.value = false; showSuccess('Profile tagihan berhasil disimpan.'); await fetchData()
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan profile tagihan.') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function remove(item) {
|
||||
const result = await showConfirm('Hapus Profile', `Hapus profile "${item.name}"?`); if (!result.isConfirmed) return
|
||||
try { await deleteBillingProfile(item.id); showSuccess('Profile berhasil dihapus.'); await fetchData() }
|
||||
catch (error) { showError(error?.response?.data?.message || 'Gagal menghapus profile.') }
|
||||
}
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CRow class="brdvx-page-row g-0 m-0"><CCol :xs="12" class="brdvx-page-column p-0"><div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header d-flex justify-content-between align-items-center"><div><h5 class="mb-0 fw-semibold">Profile Tagihan</h5><small class="text-body-secondary">Aturan jadwal tagihan bulanan customer</small></div><CButton color="primary" size="sm" @click="openForm()">+ Tambah Profile</CButton></div>
|
||||
<div v-if="loading && !loadedOnce" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div>
|
||||
<BaseResponsiveDataView v-else :columns="columns" :items="items" server-side :pagination="pagination" :loading="loading" :actions="actions" @query-change="fetchData" @action-click="({ action, item }) => action === 'edit' ? openForm(item) : remove(item)" />
|
||||
</div></CCol></CRow>
|
||||
<CModal :visible="modal" @close="modal = false" size="lg"><CModalHeader dismiss @close="modal = false"><CModalTitle>{{ editingId ? 'Edit' : 'Tambah' }} Profile Tagihan</CModalTitle></CModalHeader><CModalBody><CRow class="g-3">
|
||||
<CCol v-if="isMaster" :md="6"><CFormLabel>Tenant</CFormLabel><CFormSelect v-model="form.tenant_id"><option value="">Pilih tenant</option><option v-for="x in tenants" :key="x.id" :value="x.id">{{ x.tenant_name }}</option></CFormSelect></CCol>
|
||||
<CCol :md="6"><CFormLabel>Nama Profile</CFormLabel><CFormInput v-model="form.name" /></CCol>
|
||||
<CCol :md="6"><CFormLabel>Tipe Jadwal</CFormLabel><CFormSelect v-model="form.schedule_type"><option value="fixed_date">Tanggal tetap setiap bulan</option><option value="installation_date">Mengikuti tanggal pemasangan</option></CFormSelect></CCol>
|
||||
<template v-if="form.schedule_type === 'fixed_date'">
|
||||
<CCol :md="3"><CFormLabel>Tanggal Buat</CFormLabel><CFormInput v-model="form.invoice_day" type="number" min="1" max="28" /></CCol>
|
||||
<CCol :md="3"><CFormLabel>Tanggal Kirim</CFormLabel><CFormInput v-model="form.send_day" type="number" min="1" max="28" /></CCol>
|
||||
<CCol :md="3"><CFormLabel>Tanggal Peringatan</CFormLabel><CFormInput v-model="form.warning_day" type="number" min="1" max="28" /></CCol>
|
||||
<CCol :md="3"><CFormLabel>Tanggal Isolir</CFormLabel><CFormInput v-model="form.isolation_day" type="number" min="1" max="28" /></CCol>
|
||||
</template>
|
||||
<template v-else>
|
||||
<CCol :xs="12"><CAlert color="info" class="mb-0">Tanggal pemasangan customer menjadi hari acuan (H). Isi jumlah hari sebelum atau sesudah tanggal tersebut.</CAlert></CCol>
|
||||
<CCol :md="3"><CFormLabel>Buat Tagihan (H-)</CFormLabel><CFormInput v-model="form.invoice_days_before" type="number" min="0" max="90" /></CCol>
|
||||
<CCol :md="3"><CFormLabel>Kirim Tagihan (H-)</CFormLabel><CFormInput v-model="form.send_days_before" type="number" min="0" max="90" /></CCol>
|
||||
<CCol :md="3"><CFormLabel>Peringatan (H-)</CFormLabel><CFormInput v-model="form.warning_days_before" type="number" min="0" max="90" /><CFormText>Isi 0 agar tepat pada tanggal pemasangan.</CFormText></CCol>
|
||||
<CCol :md="3"><CFormLabel>Isolir (H+)</CFormLabel><CFormInput v-model="form.isolation_days_after" type="number" min="0" max="90" /></CCol>
|
||||
</template>
|
||||
<CCol :md="4"><CFormLabel>Jenis PPN</CFormLabel><CFormSelect v-model="form.tax_type"><option value="inclusive">Include PPN</option><option value="exclusive">Exclude PPN</option><option value="non_tax">Non PPN</option></CFormSelect></CCol>
|
||||
<CCol :md="4"><CFormLabel>Persentase PPN</CFormLabel><CFormInput v-model="form.tax_rate" type="number" min="0" max="100" :disabled="form.tax_type === 'non_tax'" /></CCol>
|
||||
<CCol :md="4"><CFormLabel>Status</CFormLabel><CFormSelect v-model="form.status"><option value="active">Aktif</option><option value="inactive">Nonaktif</option></CFormSelect></CCol>
|
||||
<CCol :xs="12"><CFormLabel>Catatan</CFormLabel><CFormTextarea v-model="form.notes" rows="2" /></CCol>
|
||||
</CRow></CModalBody><CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="save">{{ saving ? 'Menyimpan...' : 'Simpan' }}</CButton></CModalFooter></CModal>
|
||||
</template>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getTenants } from '@/services/tenantService'
|
||||
import { cancelInvoice, createInvoice, getInvoice, getInvoiceOptions, getInvoices, payInvoice, updateInvoice } from '@/services/billingService'
|
||||
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
|
||||
|
||||
const route = useRoute(), authStore = useAuthStore()
|
||||
const scope = computed(() => route.meta.invoiceScope)
|
||||
const config = computed(() => ({
|
||||
running: ['Tagihan Berjalan', 'Tagihan dari proses dibuat sampai peringatan'],
|
||||
overdue: ['Tunggakan', 'Tagihan melewati tanggal isolir dan belum lunas'],
|
||||
paid: ['Tagihan Lunas', 'Riwayat tagihan yang telah dibayar'],
|
||||
})[scope.value])
|
||||
const isMaster = computed(() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin')
|
||||
const loading = ref(true), loadedOnce = ref(false), saving = ref(false)
|
||||
const items = ref([]), tenants = ref([]), customers = ref([])
|
||||
const modal = ref(false), detailModal = ref(false), paymentModal = ref(false), editingId = ref(null), detail = ref(null)
|
||||
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
|
||||
const query = ref({ page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' })
|
||||
const form = reactive({}), payment = reactive({ amount: '', payment_method: 'cash', payment_reference: '', paid_at: '', notes: '' })
|
||||
const columns = [
|
||||
{ key: 'invoice_number', label: 'Nomor Invoice' },
|
||||
{ key: 'customer_name_snapshot', label: 'Customer' },
|
||||
{ key: 'issue_date', label: 'Tanggal Tagihan', sortKey: 'issue_date' },
|
||||
{ key: 'total_label', label: 'Total', sortKey: 'total' },
|
||||
{ key: 'balance_label', label: 'Sisa', sortKey: 'balance' },
|
||||
{ key: 'status_label', label: 'Status', sortKey: 'status' },
|
||||
]
|
||||
const statusOptions = computed(() => scope.value === 'running'
|
||||
? [['draft', 'Draft'], ['published', 'Dibuat'], ['sent', 'Terkirim'], ['warning', 'Peringatan']]
|
||||
: scope.value === 'overdue' ? [['overdue', 'Tunggakan']] : [['paid', 'Lunas']])
|
||||
const filterFields = computed(() => [
|
||||
{ key: 'date_from', label: 'Tanggal Mulai', type: 'date' },
|
||||
{ key: 'date_to', label: 'Tanggal Akhir', type: 'date' },
|
||||
{ key: 'status', label: 'Status', type: 'select', options: statusOptions.value },
|
||||
])
|
||||
const actions = computed(() => {
|
||||
const result = [{ key: 'detail', label: 'Detail', color: 'primary' }]
|
||||
if (scope.value === 'running') result.push({ key: 'edit', label: 'Edit', color: 'success' }, { key: 'pay', label: 'Bayar', color: 'info' }, { key: 'cancel', label: 'Batalkan', color: 'danger' })
|
||||
if (scope.value === 'overdue') result.push({ key: 'pay', label: 'Bayar', color: 'success' })
|
||||
return result
|
||||
})
|
||||
const tenantCustomers = computed(() => !form.tenant_id ? customers.value : customers.value.filter((x) => Number(x.tenant_id) === Number(form.tenant_id)))
|
||||
function money(value) { return `Rp ${Number(value || 0).toLocaleString('id-ID')}` }
|
||||
function normalize(item) {
|
||||
const labels = { draft: 'Draft', published: 'Dibuat', sent: 'Terkirim', warning: 'Peringatan', overdue: 'Tunggakan', paid: 'Lunas', cancelled: 'Dibatalkan' }
|
||||
return { ...item, total_label: money(item.total), balance_label: money(item.balance), status_label: labels[item.status] || item.status }
|
||||
}
|
||||
async function fetchData(params = query.value) {
|
||||
query.value = { ...query.value, ...params }; loading.value = true
|
||||
try {
|
||||
const response = await getInvoices(scope.value, query.value); const rows = response?.data?.data || []; items.value = rows.map(normalize)
|
||||
pagination.value = { current_page: response?.data?.current_page || 1, last_page: response?.data?.last_page || 1, per_page: response?.data?.per_page || 10, total: response?.data?.total || rows.length }
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Gagal memuat tagihan.') }
|
||||
finally { loading.value = false; loadedOnce.value = true }
|
||||
}
|
||||
function dateInput(value = new Date()) {
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
const pad = (number) => String(number).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
function monthDate(baseDate, day) {
|
||||
const date = new Date(`${baseDate}T00:00:00`); date.setDate(Number(day)); return dateInput(date)
|
||||
}
|
||||
function shiftedDate(baseDate, days) {
|
||||
const date = new Date(`${baseDate}T00:00:00`)
|
||||
date.setDate(date.getDate() + Number(days))
|
||||
return dateInput(date)
|
||||
}
|
||||
function installationAnchor(customer) {
|
||||
if (!customer?.activated_at || !form.period_start) return null
|
||||
const period = new Date(`${form.period_start}T00:00:00`)
|
||||
const installed = new Date(customer.activated_at)
|
||||
const lastDay = new Date(period.getFullYear(), period.getMonth() + 1, 0).getDate()
|
||||
period.setDate(Math.min(installed.getDate(), lastDay))
|
||||
return dateInput(period)
|
||||
}
|
||||
function defaults() {
|
||||
const now = new Date(), first = new Date(now.getFullYear(), now.getMonth(), 1), last = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||
return { tenant_id: '', customer_id: '', invoice_number: '', period_start: dateInput(first), period_end: dateInput(last), issue_date: dateInput(now), send_date: '', warning_date: '', isolation_date: '', subtotal: 0, notes: '' }
|
||||
}
|
||||
async function loadOptions() {
|
||||
const calls = [getInvoiceOptions()]
|
||||
if (isMaster.value) calls.push(getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' }))
|
||||
const [response, tenantResponse] = await Promise.all(calls)
|
||||
customers.value = response?.data?.customers || []; tenants.value = tenantResponse?.data?.data || []
|
||||
}
|
||||
function applyProfileSchedule() {
|
||||
const customer = customers.value.find((x) => Number(x.id) === Number(form.customer_id))
|
||||
const profile = customer?.billing_profile
|
||||
if (!profile) return
|
||||
if (profile.schedule_type === 'installation_date') {
|
||||
const anchor = installationAnchor(customer)
|
||||
if (!anchor) return
|
||||
form.issue_date = shiftedDate(anchor, -Number(profile.invoice_days_before || 0))
|
||||
form.send_date = shiftedDate(anchor, -Number(profile.send_days_before || 0))
|
||||
form.warning_date = shiftedDate(anchor, -Number(profile.warning_days_before || 0))
|
||||
form.isolation_date = shiftedDate(anchor, Number(profile.isolation_days_after || 0))
|
||||
return
|
||||
}
|
||||
if (!form.issue_date) return
|
||||
const base = form.issue_date
|
||||
form.issue_date = monthDate(base, profile.invoice_day)
|
||||
form.send_date = monthDate(base, profile.send_day)
|
||||
form.warning_date = monthDate(base, profile.warning_day)
|
||||
form.isolation_date = monthDate(base, profile.isolation_day)
|
||||
}
|
||||
function onCustomerChange() {
|
||||
const customer = customers.value.find((x) => Number(x.id) === Number(form.customer_id))
|
||||
if (customer?.billing_profile_id) applyProfileSchedule()
|
||||
}
|
||||
async function openForm(item = null) {
|
||||
await loadOptions(); editingId.value = item?.id || null; Object.keys(form).forEach((key) => delete form[key]); Object.assign(form, defaults(), item || {}); modal.value = true
|
||||
}
|
||||
async function save() {
|
||||
if (!form.customer_id) return showError('Customer wajib dipilih.')
|
||||
const customer = customers.value.find((x) => Number(x.id) === Number(form.customer_id))
|
||||
if (!customer?.billing_profile_id) return showError('Customer belum memiliki profile tagihan.')
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) await updateInvoice(editingId.value, form); else await createInvoice(form)
|
||||
modal.value = false; showSuccess('Tagihan berhasil disimpan.'); await fetchData()
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan tagihan.') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function showDetail(item) {
|
||||
try { detail.value = normalize((await getInvoice(scope.value, item.id))?.data || item); detailModal.value = true }
|
||||
catch (error) { showError(error?.response?.data?.message || 'Gagal memuat detail tagihan.') }
|
||||
}
|
||||
function openPayment(item) {
|
||||
detail.value = item; Object.assign(payment, { amount: item.balance, payment_method: 'cash', payment_reference: '', paid_at: dateInput(), notes: '' }); paymentModal.value = true
|
||||
}
|
||||
async function submitPayment() {
|
||||
saving.value = true
|
||||
try { await payInvoice(scope.value, detail.value.id, payment); paymentModal.value = false; showSuccess('Pembayaran berhasil dicatat.'); await fetchData() }
|
||||
catch (error) { showError(error?.response?.data?.message || 'Gagal mencatat pembayaran.') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function cancel(item) {
|
||||
const result = await showConfirm('Batalkan Tagihan', `Batalkan invoice "${item.invoice_number}"?`); if (!result.isConfirmed) return
|
||||
try { await cancelInvoice(item.id); showSuccess('Tagihan dibatalkan.'); await fetchData() }
|
||||
catch (error) { showError(error?.response?.data?.message || 'Gagal membatalkan tagihan.') }
|
||||
}
|
||||
function action({ action, item }) {
|
||||
if (action === 'detail') showDetail(item)
|
||||
if (action === 'edit') openForm(item)
|
||||
if (action === 'pay') openPayment(item)
|
||||
if (action === 'cancel') cancel(item)
|
||||
}
|
||||
watch(scope, () => { loadedOnce.value = false; query.value = { page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' }; fetchData() })
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CRow class="brdvx-page-row g-0 m-0"><CCol :xs="12" class="brdvx-page-column p-0"><div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header d-flex justify-content-between align-items-center"><div><h5 class="mb-0 fw-semibold">{{ config[0] }}</h5><small class="text-body-secondary">{{ config[1] }}</small></div><CButton v-if="scope === 'running'" color="primary" size="sm" @click="openForm()">+ Buat Tagihan</CButton></div>
|
||||
<div v-if="loading && !loadedOnce" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div>
|
||||
<BaseResponsiveDataView v-else :columns="columns" :items="items" server-side :pagination="pagination" :loading="loading" :actions="actions" :filter-fields="filterFields" @query-change="fetchData" @action-click="action" />
|
||||
</div></CCol></CRow>
|
||||
|
||||
<CModal :visible="modal" @close="modal = false" size="lg"><CModalHeader dismiss @close="modal = false"><CModalTitle>{{ editingId ? 'Edit' : 'Buat' }} Tagihan</CModalTitle></CModalHeader><CModalBody><CRow class="g-3">
|
||||
<CCol v-if="isMaster" :md="6"><CFormLabel>Tenant</CFormLabel><CFormSelect v-model="form.tenant_id"><option value="">Pilih tenant</option><option v-for="x in tenants" :key="x.id" :value="x.id">{{ x.tenant_name }}</option></CFormSelect></CCol>
|
||||
<CCol :md="6"><CFormLabel>Customer</CFormLabel><CFormSelect v-model="form.customer_id" @change="onCustomerChange"><option value="">Pilih customer</option><option v-for="x in tenantCustomers" :key="x.id" :value="x.id">{{ x.customer_code }} — {{ x.name }}</option></CFormSelect></CCol>
|
||||
<CCol :md="6"><CFormLabel>Nomor Invoice</CFormLabel><CFormInput v-model="form.invoice_number" placeholder="Otomatis jika kosong" /></CCol>
|
||||
<CCol :md="4"><CFormLabel>Periode Mulai</CFormLabel><CFormInput v-model="form.period_start" type="date" @change="applyProfileSchedule" /></CCol><CCol :md="4"><CFormLabel>Periode Akhir</CFormLabel><CFormInput v-model="form.period_end" type="date" /></CCol><CCol :md="4"><CFormLabel>Subtotal</CFormLabel><CFormInput v-model="form.subtotal" type="number" min="0" /></CCol>
|
||||
<CCol :md="3"><CFormLabel>Tanggal Buat</CFormLabel><CFormInput v-model="form.issue_date" type="date" /></CCol><CCol :md="3"><CFormLabel>Tanggal Kirim</CFormLabel><CFormInput v-model="form.send_date" type="date" /></CCol><CCol :md="3"><CFormLabel>Peringatan</CFormLabel><CFormInput v-model="form.warning_date" type="date" /></CCol><CCol :md="3"><CFormLabel>Isolir</CFormLabel><CFormInput v-model="form.isolation_date" type="date" /></CCol>
|
||||
<CCol :xs="12"><CFormLabel>Catatan</CFormLabel><CFormTextarea v-model="form.notes" rows="2" /></CCol>
|
||||
</CRow></CModalBody><CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="save">{{ saving ? 'Menyimpan...' : 'Simpan' }}</CButton></CModalFooter></CModal>
|
||||
|
||||
<CModal :visible="paymentModal" @close="paymentModal = false"><CModalHeader dismiss @close="paymentModal = false"><CModalTitle>Catat Pembayaran Manual</CModalTitle></CModalHeader><CModalBody><div class="d-grid gap-3"><div><CFormLabel>Jumlah Bayar</CFormLabel><CFormInput v-model="payment.amount" type="number" /></div><div><CFormLabel>Metode</CFormLabel><CFormSelect v-model="payment.payment_method"><option value="cash">Tunai</option><option value="transfer">Transfer</option><option value="other">Lainnya</option></CFormSelect></div><div><CFormLabel>Referensi</CFormLabel><CFormInput v-model="payment.payment_reference" /></div><div><CFormLabel>Tanggal Bayar</CFormLabel><CFormInput v-model="payment.paid_at" type="date" /></div></div></CModalBody><CModalFooter><CButton color="secondary" @click="paymentModal = false">Batal</CButton><CButton color="success" :disabled="saving" @click="submitPayment">Simpan Pembayaran</CButton></CModalFooter></CModal>
|
||||
|
||||
<CModal :visible="detailModal" @close="detailModal = false"><CModalHeader dismiss @close="detailModal = false"><CModalTitle>Detail Tagihan</CModalTitle></CModalHeader><CModalBody v-if="detail"><div class="d-grid gap-2"><div class="d-flex justify-content-between"><span>Invoice</span><strong>{{ detail.invoice_number }}</strong></div><div class="d-flex justify-content-between"><span>Customer</span><strong>{{ detail.customer_name_snapshot }}</strong></div><div class="d-flex justify-content-between"><span>Total</span><strong>{{ detail.total_label }}</strong></div><div class="d-flex justify-content-between"><span>Terbayar</span><strong>{{ money(detail.paid_amount) }}</strong></div><div class="d-flex justify-content-between"><span>Sisa</span><strong>{{ detail.balance_label }}</strong></div><div class="d-flex justify-content-between"><span>Status</span><CBadge color="primary">{{ detail.status_label }}</CBadge></div></div></CModalBody><CModalFooter><CButton color="secondary" @click="detailModal = false">Tutup</CButton></CModalFooter></CModal>
|
||||
</template>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getCustomer } from '@/services/customerService'
|
||||
import { getTemporaryFileUrl } from '@/services/fileService'
|
||||
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
|
||||
import CustomerLocationMap from '@/components/customers/CustomerLocationMap.vue'
|
||||
import {
|
||||
createCustomerNotificationContact, deleteCustomerNotificationContact,
|
||||
getCustomerNotificationContacts, updateCustomerNotificationContact,
|
||||
} from '@/services/notificationService'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const customer = ref(null)
|
||||
const gallery = ref([])
|
||||
const selectedImage = ref(0)
|
||||
const contacts = ref([])
|
||||
const contactModal = ref(false)
|
||||
const editingContactId = ref(null)
|
||||
const contactForm = reactive({})
|
||||
const scope = computed(() => route.params.scope)
|
||||
const activeImage = computed(() => gallery.value[selectedImage.value] || null)
|
||||
const statusLabel = computed(() => ({
|
||||
order: 'Order', active: 'Aktif', inactive: 'Tidak Aktif', unmanaged: 'Unmanage',
|
||||
})[customer.value?.status] || customer.value?.status || '-')
|
||||
const statusColor = computed(() => ({
|
||||
order: 'info', active: 'success', inactive: 'warning', unmanaged: 'secondary',
|
||||
})[customer.value?.status] || 'secondary')
|
||||
|
||||
async function hydrateGallery(images) {
|
||||
gallery.value = images.map((image) => ({ ...image, url: '/customer-default.svg' }))
|
||||
selectedImage.value = Math.max(0, gallery.value.findIndex((image) => image.is_cover))
|
||||
await Promise.allSettled(gallery.value.map(async (image) => {
|
||||
if (!image.uuid) return
|
||||
const response = await getTemporaryFileUrl(image.uuid)
|
||||
image.url = response?.data?.url || '/customer-default.svg'
|
||||
}))
|
||||
}
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getCustomer(scope.value, route.params.id)
|
||||
customer.value = response?.data || null
|
||||
if (scope.value === 'active') {
|
||||
contacts.value = (await getCustomerNotificationContacts(customer.value.id))?.data || []
|
||||
}
|
||||
loading.value = false
|
||||
hydrateGallery(customer.value?.images || [])
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || 'Gagal memuat detail customer.')
|
||||
router.replace(`/customers/${scope.value}`)
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
function back() { router.push(`/customers/${scope.value}`) }
|
||||
function openContact(contact = null) {
|
||||
editingContactId.value = contact?.id || null
|
||||
Object.assign(contactForm, {
|
||||
type: contact?.type || 'whatsapp', value: contact?.value || '', label: contact?.label || '',
|
||||
is_primary: contact?.is_primary ?? true, is_verified: contact?.is_verified ?? false,
|
||||
can_receive_transactional: contact?.can_receive_transactional ?? true,
|
||||
can_receive_broadcast: contact?.can_receive_broadcast ?? true,
|
||||
opted_out_at: contact?.opted_out_at || null,
|
||||
})
|
||||
contactModal.value = true
|
||||
}
|
||||
async function saveContact() {
|
||||
try {
|
||||
if (editingContactId.value) await updateCustomerNotificationContact(customer.value.id, editingContactId.value, contactForm)
|
||||
else await createCustomerNotificationContact(customer.value.id, contactForm)
|
||||
contacts.value = (await getCustomerNotificationContacts(customer.value.id))?.data || []
|
||||
contactModal.value = false; showSuccess('Kontak notifikasi berhasil disimpan.')
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan kontak notifikasi.') }
|
||||
}
|
||||
async function removeContact(contact) {
|
||||
const result = await showConfirm('Hapus Kontak', `Hapus kontak "${contact.value}"?`); if (!result.isConfirmed) return
|
||||
try {
|
||||
await deleteCustomerNotificationContact(customer.value.id, contact.id)
|
||||
contacts.value = contacts.value.filter((item) => item.id !== contact.id)
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Kontak tidak dapat dihapus.') }
|
||||
}
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="customer-detail-page">
|
||||
<div class="detail-header">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<img :src="activeImage?.url || '/customer-default.svg'" alt="Cover customer" class="detail-avatar image-previewable" loading="lazy" decoding="async" />
|
||||
<div>
|
||||
<CButton color="secondary" variant="ghost" size="sm" class="px-0" @click="back">← Kembali</CButton>
|
||||
<h4 class="mb-1">{{ customer?.name || 'Detail Customer' }}</h4>
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<span class="text-body-secondary">{{ customer?.customer_code || '-' }}</span>
|
||||
<CBadge :color="statusColor">{{ statusLabel }}</CBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CButton v-if="scope === 'orders' && customer" color="primary" @click="router.push(`/customers/orders/${customer.id}/edit`)">Edit Customer</CButton>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="detail-loading"><CSpinner color="primary" /><span>Memuat detail customer...</span></div>
|
||||
<CRow v-else-if="customer" class="g-3">
|
||||
<CCol :xl="8">
|
||||
<CCard class="detail-card mb-3"><CCardHeader><strong>Profil Customer</strong></CCardHeader><CCardBody>
|
||||
<div class="info-grid">
|
||||
<div><span>Nama</span><strong>{{ customer.name }}</strong></div>
|
||||
<div><span>NIK/Identitas</span><strong>{{ customer.identity_number || '-' }}</strong></div>
|
||||
<div><span>Email</span><strong>{{ customer.email || '-' }}</strong></div>
|
||||
<div><span>WhatsApp</span><strong>{{ customer.whatsapp_number || '-' }}</strong></div>
|
||||
<div><span>Tenant</span><strong>{{ customer.tenant?.tenant_name || '-' }}</strong></div>
|
||||
<div><span>Username Eksternal</span><strong>{{ customer.external_username || '-' }}</strong></div>
|
||||
</div>
|
||||
</CCardBody></CCard>
|
||||
|
||||
<CCard class="detail-card mb-3"><CCardHeader><strong>Lokasi Pemasangan</strong></CCardHeader><CCardBody>
|
||||
<p class="mb-3">{{ customer.full_installation_address || '-' }}</p>
|
||||
<CustomerLocationMap
|
||||
v-if="customer.latitude && customer.longitude"
|
||||
:latitude="customer.latitude"
|
||||
:longitude="customer.longitude"
|
||||
readonly
|
||||
/>
|
||||
<div v-else class="detail-map-empty">Koordinat lokasi pemasangan belum tersedia.</div>
|
||||
</CCardBody></CCard>
|
||||
|
||||
<CCard class="detail-card"><CCardHeader class="d-flex justify-content-between"><strong>Dokumentasi</strong><small>{{ gallery.length }} gambar</small></CCardHeader><CCardBody>
|
||||
<div class="detail-gallery-main">
|
||||
<img :src="activeImage?.url || '/customer-default.svg'" alt="Dokumentasi customer" class="image-previewable" loading="lazy" decoding="async" />
|
||||
<div v-if="activeImage" class="detail-caption">{{ activeImage.caption || activeImage.original_name }}</div>
|
||||
</div>
|
||||
<div v-if="gallery.length" class="detail-thumbnails">
|
||||
<button v-for="(image, index) in gallery" :key="image.uuid" type="button" :class="{ active: selectedImage === index }" @click="selectedImage = index">
|
||||
<img :src="image.url" :alt="image.original_name" loading="lazy" decoding="async" />
|
||||
</button>
|
||||
</div>
|
||||
</CCardBody></CCard>
|
||||
</CCol>
|
||||
|
||||
<CCol :xl="4">
|
||||
<div class="detail-sidebar">
|
||||
<CCard class="detail-card mb-3"><CCardHeader><strong>Layanan</strong></CCardHeader><CCardBody class="info-stack">
|
||||
<div><span>Profile Paket</span><strong>{{ customer.package_profile?.name || '-' }}</strong></div>
|
||||
<div><span>NAS Mikrotik</span><strong>{{ customer.nas_mikrotik?.name || '-' }}</strong></div>
|
||||
<div><span>Profile Tagihan</span><strong>{{ customer.billing_profile?.name || '-' }}</strong></div>
|
||||
<div><span>Tipe Koneksi</span><strong>{{ customer.nas_mikrotik?.connection_type || '-' }}</strong></div>
|
||||
<div><span>Tahap Order</span><strong>{{ customer.order_stage || '-' }}</strong></div>
|
||||
<div><span>Aktivasi</span><strong>{{ customer.activated_at || '-' }}</strong></div>
|
||||
</CCardBody></CCard>
|
||||
<CCard v-if="scope === 'active'" class="detail-card mb-3"><CCardHeader class="d-flex justify-content-between align-items-center"><strong>Kontak Notifikasi</strong><CButton color="primary" size="sm" @click="openContact()">+ Kontak</CButton></CCardHeader><CCardBody>
|
||||
<div v-if="contacts.length" class="notification-contact-list">
|
||||
<div v-for="contact in contacts" :key="contact.id"><div><strong>{{ contact.label || contact.type }}</strong><span>{{ contact.value }}</span><small>{{ contact.can_receive_broadcast ? 'Transaksional & siaran' : 'Hanya transaksional' }}</small></div><div><CButton size="sm" color="success" variant="outline" @click="openContact(contact)">Edit</CButton><CButton size="sm" color="danger" variant="outline" @click="removeContact(contact)">Hapus</CButton></div></div>
|
||||
</div><div v-else class="text-body-secondary">Belum ada kontak notifikasi.</div>
|
||||
</CCardBody></CCard>
|
||||
<CCard class="detail-card"><CCardHeader><strong>Catatan</strong></CCardHeader><CCardBody><p class="mb-0 text-pre-wrap">{{ customer.notes || 'Tidak ada catatan.' }}</p></CCardBody></CCard>
|
||||
</div>
|
||||
</CCol>
|
||||
</CRow>
|
||||
<CModal :visible="contactModal" @close="contactModal = false"><CModalHeader dismiss @close="contactModal = false"><CModalTitle>Kontak Notifikasi</CModalTitle></CModalHeader><CModalBody><div class="d-grid gap-3">
|
||||
<div><CFormLabel>Jenis</CFormLabel><CFormSelect v-model="contactForm.type"><option value="whatsapp">WhatsApp</option><option value="email">Email</option><option value="telegram">Telegram Chat ID</option></CFormSelect></div>
|
||||
<div><CFormLabel>Nomor/Alamat/Chat ID</CFormLabel><CFormInput v-model="contactForm.value" /></div>
|
||||
<div><CFormLabel>Label</CFormLabel><CFormInput v-model="contactForm.label" /></div>
|
||||
<CFormCheck v-model="contactForm.is_primary" label="Kontak utama" /><CFormCheck v-model="contactForm.can_receive_transactional" label="Terima pesan transaksional" /><CFormCheck v-model="contactForm.can_receive_broadcast" label="Terima pesan siaran" /><CFormCheck v-model="contactForm.is_verified" label="Kontak terverifikasi" />
|
||||
</div></CModalBody><CModalFooter><CButton color="secondary" @click="contactModal = false">Batal</CButton><CButton color="primary" @click="saveContact">Simpan</CButton></CModalFooter></CModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customer-detail-page { padding: 0.5rem; }
|
||||
.detail-header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; margin-bottom: 1rem; }
|
||||
.detail-avatar { width: 5.25rem; height: 5.25rem; object-fit: cover; border: 3px solid var(--cui-body-bg); border-radius: 1rem; box-shadow: 0 0.2rem 0.8rem rgba(0,0,0,.14); }
|
||||
.detail-loading { min-height: 18rem; display: flex; align-items: center; justify-content: center; gap: .75rem; }
|
||||
.detail-card { overflow: hidden; border: 1px solid var(--cui-border-color); border-radius: .85rem; box-shadow: 0 .2rem .7rem rgba(0,0,0,.04); }
|
||||
.info-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 1rem; }
|
||||
.info-grid div, .info-stack div { display: flex; flex-direction: column; gap: .2rem; min-width: 0; }
|
||||
.info-grid span, .info-stack span { color: var(--cui-secondary-color); font-size: .8rem; }
|
||||
.info-grid strong, .info-stack strong { overflow-wrap: anywhere; }
|
||||
.info-stack { display: grid; gap: 1rem; }
|
||||
.detail-map-empty { min-height: 10rem; display: flex; align-items: center; justify-content: center; color: var(--cui-secondary-color); border-radius: .75rem; background: var(--cui-tertiary-bg); }
|
||||
.detail-gallery-main { position: relative; overflow: hidden; aspect-ratio: 16/8; border-radius: .75rem; background: var(--cui-tertiary-bg); }
|
||||
.detail-gallery-main img { width: 100%; height: 100%; object-fit: cover; cursor: zoom-in; }
|
||||
.detail-caption { position: absolute; inset: auto 0 0; padding: 1.5rem 1rem .75rem; color: white; background: linear-gradient(transparent, rgba(0,0,0,.8)); }
|
||||
.detail-thumbnails { display: flex; gap: .55rem; overflow-x: auto; padding-top: .75rem; }
|
||||
.detail-thumbnails button { width: 5rem; height: 4rem; flex: 0 0 auto; overflow: hidden; padding: 0; border: 2px solid transparent; border-radius: .55rem; }
|
||||
.detail-thumbnails button.active { border-color: var(--cui-primary); }
|
||||
.detail-thumbnails img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.detail-sidebar { position: sticky; top: 5.5rem; }
|
||||
.text-pre-wrap { white-space: pre-wrap; }
|
||||
.notification-contact-list { display: grid; gap: .65rem; }
|
||||
.notification-contact-list > div { display: flex; justify-content: space-between; gap: .5rem; padding-bottom: .65rem; border-bottom: 1px solid var(--cui-border-color); }
|
||||
.notification-contact-list > div > div:first-child { display: flex; min-width: 0; flex-direction: column; }
|
||||
.notification-contact-list span, .notification-contact-list small { overflow-wrap: anywhere; }
|
||||
.notification-contact-list small { color: var(--cui-secondary-color); }
|
||||
.notification-contact-list > div > div:last-child { display: flex; align-items: flex-start; gap: .35rem; }
|
||||
@media (max-width: 1199.98px) { .detail-sidebar { position: static; } }
|
||||
@media (max-width: 767.98px) {
|
||||
.customer-detail-page { padding: 0; }
|
||||
.detail-header { align-items: flex-start; flex-direction: column; }
|
||||
.detail-header > button { width: 100%; }
|
||||
.detail-avatar { width: 4.25rem; height: 4.25rem; }
|
||||
.info-grid { grid-template-columns: 1fr; }
|
||||
.detail-gallery-main { aspect-ratio: 4/3; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,290 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { createCustomer, getCustomer, getCustomerOptions, updateCustomer } from '@/services/customerService'
|
||||
import { getTenants } from '@/services/tenantService'
|
||||
import { deleteFile, getTemporaryFileUrl, uploadFile } from '@/services/fileService'
|
||||
import { getDesaOptions, getKabupatenOptions, getKecamatanOptions, getProvinsiOptions } from '@/services/wilayahService'
|
||||
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
|
||||
import { compressCustomerImage } from '@/utils/imageCompression'
|
||||
import CustomerLocationMap from '@/components/customers/CustomerLocationMap.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const uploading = ref(false)
|
||||
const fileInput = ref(null)
|
||||
const selectedImage = ref(0)
|
||||
const tenants = ref([]), profiles = ref([]), mikrotiks = ref([]), billingProfiles = ref([])
|
||||
const provinsi = ref([]), kabupaten = ref([]), kecamatan = ref([]), desa = ref([])
|
||||
const gallery = ref([])
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const isMaster = computed(() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin')
|
||||
const form = reactive({
|
||||
tenant_id: '', customer_code: '', name: '', identity_number: '', email: '', whatsapp_number: '',
|
||||
installation_address: '', provinsi_id: '', kabupaten_id: '', kecamatan_id: '', desa_id: '',
|
||||
latitude: '', longitude: '', package_profile_id: '', nas_mikrotik_id: '', external_username: '',
|
||||
billing_profile_id: '', order_stage: 'registration', notes: '',
|
||||
})
|
||||
const categoryOptions = [
|
||||
['house', 'Rumah'], ['installation', 'Instalasi'], ['modem', 'Modem/ONT'],
|
||||
['odp', 'ODP'], ['cable', 'Jalur Kabel'], ['identity', 'Identitas'], ['other', 'Lainnya'],
|
||||
]
|
||||
const activeImage = computed(() => gallery.value[selectedImage.value] || null)
|
||||
const tenantProfiles = computed(() => !form.tenant_id ? profiles.value : profiles.value.filter((x) => Number(x.tenant_id) === Number(form.tenant_id)))
|
||||
const tenantNas = computed(() => !form.tenant_id ? mikrotiks.value : mikrotiks.value.filter((x) => Number(x.tenant_id) === Number(form.tenant_id)))
|
||||
const tenantBillingProfiles = computed(() => !form.tenant_id ? billingProfiles.value : billingProfiles.value.filter((x) => Number(x.tenant_id) === Number(form.tenant_id)))
|
||||
|
||||
function optionRows(response) { return Array.isArray(response?.data) ? response.data : [] }
|
||||
async function loadReferences() {
|
||||
const calls = [getProvinsiOptions(), getCustomerOptions()]
|
||||
if (isMaster.value) calls.push(getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' }))
|
||||
const [provinceResponse, customerOptions, tenantResponse] = await Promise.all(calls)
|
||||
provinsi.value = optionRows(provinceResponse)
|
||||
profiles.value = customerOptions?.data?.package_profiles || []
|
||||
mikrotiks.value = customerOptions?.data?.nas_mikrotiks || []
|
||||
billingProfiles.value = customerOptions?.data?.billing_profiles || []
|
||||
tenants.value = tenantResponse?.data?.data || []
|
||||
}
|
||||
async function loadKabupaten(reset = true) {
|
||||
if (reset) Object.assign(form, { kabupaten_id: '', kecamatan_id: '', desa_id: '' })
|
||||
kabupaten.value = form.provinsi_id ? optionRows(await getKabupatenOptions(form.provinsi_id)) : []
|
||||
}
|
||||
async function loadKecamatan(reset = true) {
|
||||
if (reset) Object.assign(form, { kecamatan_id: '', desa_id: '' })
|
||||
kecamatan.value = form.kabupaten_id ? optionRows(await getKecamatanOptions(form.kabupaten_id)) : []
|
||||
}
|
||||
async function loadDesa(reset = true) {
|
||||
if (reset) form.desa_id = ''
|
||||
desa.value = form.kecamatan_id ? optionRows(await getDesaOptions(form.kecamatan_id)) : []
|
||||
}
|
||||
async function onProvinsiChange(event) { form.provinsi_id = event.target.value; await loadKabupaten() }
|
||||
async function onKabupatenChange(event) { form.kabupaten_id = event.target.value; await loadKecamatan() }
|
||||
async function onKecamatanChange(event) { form.kecamatan_id = event.target.value; await loadDesa() }
|
||||
|
||||
async function resolveImageUrl(image) {
|
||||
try {
|
||||
const response = await getTemporaryFileUrl(image.uuid)
|
||||
return response?.data?.url || '/customer-default.svg'
|
||||
} catch {
|
||||
return '/customer-default.svg'
|
||||
}
|
||||
}
|
||||
async function hydrateGallery(images = []) {
|
||||
gallery.value = await Promise.all(images.map(async (image) => ({
|
||||
...image,
|
||||
url: await resolveImageUrl(image),
|
||||
isNew: false,
|
||||
})))
|
||||
selectedImage.value = Math.max(0, gallery.value.findIndex((image) => image.is_cover))
|
||||
}
|
||||
async function loadCustomer() {
|
||||
if (!isEdit.value) return
|
||||
const response = await getCustomer('orders', route.params.id)
|
||||
const customer = response?.data || {}
|
||||
Object.keys(form).forEach((key) => {
|
||||
if (Object.prototype.hasOwnProperty.call(customer, key)) form[key] = customer[key] ?? ''
|
||||
})
|
||||
if (form.provinsi_id) await loadKabupaten(false)
|
||||
if (form.kabupaten_id) await loadKecamatan(false)
|
||||
if (form.kecamatan_id) await loadDesa(false)
|
||||
await hydrateGallery(customer.images || [])
|
||||
}
|
||||
async function initialize() {
|
||||
loading.value = true
|
||||
try {
|
||||
await loadReferences()
|
||||
await loadCustomer()
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || 'Gagal memuat form customer.')
|
||||
router.replace('/customers/orders')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
async function addImages(event) {
|
||||
const files = [...(event.target.files || [])].filter((file) => file.type.startsWith('image/'))
|
||||
if (!files.length) return
|
||||
if (gallery.value.length + files.length > 20) {
|
||||
showError('Maksimal 20 gambar untuk satu customer.'); event.target.value = ''; return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
for (const file of files) {
|
||||
const compressedFile = await compressCustomerImage(file)
|
||||
const response = await uploadFile(compressedFile, 'customer-image')
|
||||
const uploaded = response?.data
|
||||
const tempResponse = await getTemporaryFileUrl(uploaded.uuid)
|
||||
gallery.value.push({
|
||||
stored_file_id: uploaded.id, uuid: uploaded.uuid, original_name: uploaded.original_name,
|
||||
category: 'other', caption: '', is_cover: gallery.value.length === 0,
|
||||
sort_order: gallery.value.length, url: tempResponse?.data?.url || URL.createObjectURL(compressedFile), isNew: true,
|
||||
})
|
||||
}
|
||||
selectedImage.value = gallery.value.length - 1
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Gagal mengunggah gambar.') }
|
||||
finally { uploading.value = false; event.target.value = '' }
|
||||
}
|
||||
function setCover(index) {
|
||||
gallery.value.forEach((image, imageIndex) => { image.is_cover = imageIndex === index })
|
||||
selectedImage.value = index
|
||||
}
|
||||
async function removeImage(index) {
|
||||
const image = gallery.value[index]
|
||||
const result = await showConfirm('Hapus Gambar', `Hapus "${image.original_name}" dari galeri?`)
|
||||
if (!result.isConfirmed) return
|
||||
if (image.isNew) {
|
||||
try { await deleteFile(image.uuid) } catch { /* relasi tetap dibuang dari form */ }
|
||||
}
|
||||
gallery.value.splice(index, 1)
|
||||
gallery.value.forEach((item, itemIndex) => { item.sort_order = itemIndex })
|
||||
if (gallery.value.length && !gallery.value.some((item) => item.is_cover)) gallery.value[0].is_cover = true
|
||||
selectedImage.value = Math.min(selectedImage.value, Math.max(0, gallery.value.length - 1))
|
||||
}
|
||||
function useCurrentLocation() {
|
||||
if (!navigator.geolocation) return showError('Browser tidak mendukung geolocation.')
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
({ coords }) => { form.latitude = coords.latitude.toFixed(7); form.longitude = coords.longitude.toFixed(7) },
|
||||
() => showError('Lokasi tidak dapat diambil. Pastikan izin lokasi sudah diberikan.'),
|
||||
{ enableHighAccuracy: true, timeout: 15000 },
|
||||
)
|
||||
}
|
||||
function payload() {
|
||||
const data = Object.fromEntries(Object.entries(form).map(([key, value]) => [key, value === '' ? null : value]))
|
||||
data.images = gallery.value.map((image, index) => ({
|
||||
stored_file_id: image.stored_file_id, category: image.category, caption: image.caption || null,
|
||||
is_cover: !!image.is_cover, sort_order: index,
|
||||
}))
|
||||
return data
|
||||
}
|
||||
async function save() {
|
||||
if (!form.name?.trim()) return showError('Nama customer wajib diisi.')
|
||||
if (isMaster.value && !form.tenant_id) return showError('Tenant wajib dipilih.')
|
||||
saving.value = true
|
||||
try {
|
||||
const response = isEdit.value
|
||||
? await updateCustomer(route.params.id, payload())
|
||||
: await createCustomer(payload())
|
||||
showSuccess(`Customer berhasil ${isEdit.value ? 'diperbarui' : 'didaftarkan'}.`)
|
||||
router.replace(`/customers/orders/${response?.data?.id || route.params.id}/edit`)
|
||||
if (!isEdit.value) await initialize()
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan customer.') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
onMounted(initialize)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="customer-form-page">
|
||||
<div class="customer-page-header">
|
||||
<div>
|
||||
<CButton color="secondary" variant="ghost" size="sm" @click="router.push('/customers/orders')">← Kembali</CButton>
|
||||
<h4 class="mb-1 mt-1">{{ isEdit ? 'Lengkapi Data Customer' : 'Registrasi Customer' }}</h4>
|
||||
<p class="text-body-secondary mb-0">Identitas, lokasi pemasangan, layanan, dan dokumentasi customer.</p>
|
||||
</div>
|
||||
<CButton color="primary" size="sm" :disabled="loading || saving || uploading" @click="save">
|
||||
{{ saving ? 'Menyimpan...' : 'Simpan Customer' }}
|
||||
</CButton>
|
||||
</div>
|
||||
<div v-if="loading" class="form-loading"><CSpinner color="primary" /><span>Memuat data...</span></div>
|
||||
<CRow v-else class="g-3">
|
||||
<CCol :xl="8">
|
||||
<CCard class="form-card mb-3"><CCardHeader><strong>Informasi Customer</strong></CCardHeader><CCardBody>
|
||||
<CRow class="g-3">
|
||||
<CCol v-if="isMaster" :md="6"><CFormLabel>Tenant</CFormLabel><CFormSelect v-model="form.tenant_id"><option value="">Pilih tenant</option><option v-for="x in tenants" :key="x.id" :value="x.id">{{ x.tenant_name }}</option></CFormSelect></CCol>
|
||||
<CCol :md="6"><CFormLabel>ID Customer</CFormLabel><CFormInput v-model="form.customer_code" placeholder="Dibuat otomatis jika kosong" /></CCol>
|
||||
<CCol :md="6"><CFormLabel>Nama Lengkap</CFormLabel><CFormInput v-model="form.name" /></CCol>
|
||||
<CCol :md="6"><CFormLabel>NIK/Identitas</CFormLabel><CFormInput v-model="form.identity_number" /></CCol>
|
||||
<CCol :md="6"><CFormLabel>Email</CFormLabel><CFormInput v-model="form.email" type="email" /></CCol>
|
||||
<CCol :md="6"><CFormLabel>WhatsApp</CFormLabel><CFormInput v-model="form.whatsapp_number" /></CCol>
|
||||
</CRow>
|
||||
</CCardBody></CCard>
|
||||
|
||||
<CCard class="form-card mb-3"><CCardHeader><strong>Lokasi Pemasangan</strong></CCardHeader><CCardBody>
|
||||
<CRow class="g-3">
|
||||
<CCol :xs="12"><CFormLabel>Alamat</CFormLabel><CFormTextarea v-model="form.installation_address" rows="2" /></CCol>
|
||||
<CCol :md="6" :xl="3"><CFormLabel>Provinsi</CFormLabel><CFormSelect v-model="form.provinsi_id" @change="onProvinsiChange"><option value="">Pilih</option><option v-for="x in provinsi" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
|
||||
<CCol :md="6" :xl="3"><CFormLabel>Kabupaten</CFormLabel><CFormSelect v-model="form.kabupaten_id" @change="onKabupatenChange"><option value="">Pilih</option><option v-for="x in kabupaten" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
|
||||
<CCol :md="6" :xl="3"><CFormLabel>Kecamatan</CFormLabel><CFormSelect v-model="form.kecamatan_id" @change="onKecamatanChange"><option value="">Pilih</option><option v-for="x in kecamatan" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
|
||||
<CCol :md="6" :xl="3"><CFormLabel>Desa</CFormLabel><CFormSelect v-model="form.desa_id"><option value="">Pilih</option><option v-for="x in desa" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
|
||||
</CRow>
|
||||
<div class="map-shell mt-3">
|
||||
<CustomerLocationMap
|
||||
v-model:latitude="form.latitude"
|
||||
v-model:longitude="form.longitude"
|
||||
/>
|
||||
<div class="map-coordinates">
|
||||
<div><CFormLabel>Latitude</CFormLabel><CFormInput v-model="form.latitude" type="number" step="any" /></div>
|
||||
<div><CFormLabel>Longitude</CFormLabel><CFormInput v-model="form.longitude" type="number" step="any" /></div>
|
||||
<CButton color="primary" variant="outline" @click="useCurrentLocation">Gunakan Lokasi Saya</CButton>
|
||||
</div>
|
||||
</div>
|
||||
</CCardBody></CCard>
|
||||
|
||||
<CCard class="form-card"><CCardHeader class="d-flex justify-content-between"><strong>Dokumentasi Lokasi</strong><small>{{ gallery.length }}/20 gambar</small></CCardHeader><CCardBody>
|
||||
<div class="gallery-main">
|
||||
<img :src="activeImage?.url || '/customer-default.svg'" alt="Dokumentasi customer" class="image-previewable" />
|
||||
<div v-if="activeImage" class="gallery-overlay"><CBadge color="primary">{{ categoryOptions.find(([value]) => value === activeImage.category)?.[1] }}</CBadge><span>{{ activeImage.caption || activeImage.original_name }}</span></div>
|
||||
</div>
|
||||
<div class="gallery-thumbnails">
|
||||
<button v-for="(image, index) in gallery" :key="image.uuid" type="button" class="gallery-thumb" :class="{ active: index === selectedImage }" @click="selectedImage = index">
|
||||
<img :src="image.url" :alt="image.original_name" /><span v-if="image.is_cover">Cover</span>
|
||||
</button>
|
||||
<button type="button" class="gallery-add" :disabled="uploading || gallery.length >= 20" @click="fileInput?.click()"><strong>+</strong><span>{{ uploading ? 'Upload...' : 'Tambah' }}</span></button>
|
||||
<input ref="fileInput" type="file" accept="image/*" multiple hidden @change="addImages" />
|
||||
</div>
|
||||
<CRow v-if="activeImage" class="g-2 mt-2 align-items-end">
|
||||
<CCol :md="4"><CFormLabel>Kategori</CFormLabel><CFormSelect v-model="activeImage.category"><option v-for="[value, label] in categoryOptions" :key="value" :value="value">{{ label }}</option></CFormSelect></CCol>
|
||||
<CCol :md="5"><CFormLabel>Keterangan</CFormLabel><CFormInput v-model="activeImage.caption" /></CCol>
|
||||
<CCol :md="3" class="d-flex gap-2"><CButton color="primary" variant="outline" class="flex-fill" @click="setCover(selectedImage)">Jadikan Cover</CButton><CButton color="danger" variant="outline" @click="removeImage(selectedImage)">Hapus</CButton></CCol>
|
||||
</CRow>
|
||||
</CCardBody></CCard>
|
||||
</CCol>
|
||||
|
||||
<CCol :xl="4">
|
||||
<div class="customer-sidebar">
|
||||
<CCard class="form-card mb-3"><CCardHeader><strong>Layanan Customer</strong></CCardHeader><CCardBody class="d-grid gap-3">
|
||||
<div><CFormLabel>Profile Paket</CFormLabel><CFormSelect v-model="form.package_profile_id"><option value="">Pilih profile paket</option><option v-for="x in tenantProfiles" :key="x.id" :value="x.id">{{ x.name }}</option></CFormSelect></div>
|
||||
<div><CFormLabel>NAS Mikrotik</CFormLabel><CFormSelect v-model="form.nas_mikrotik_id"><option value="">Pilih NAS</option><option v-for="x in tenantNas" :key="x.id" :value="x.id">{{ x.name }}</option></CFormSelect></div>
|
||||
<div><CFormLabel>Profile Tagihan</CFormLabel><CFormSelect v-model="form.billing_profile_id"><option value="">Pilih profile tagihan</option><option v-for="x in tenantBillingProfiles" :key="x.id" :value="x.id">{{ x.name }}</option></CFormSelect></div>
|
||||
<div><CFormLabel>Username Eksternal</CFormLabel><CFormInput v-model="form.external_username" /></div>
|
||||
<div><CFormLabel>Tahap Order</CFormLabel><CFormSelect v-model="form.order_stage"><option value="registration">Registrasi</option><option value="data_completion">Melengkapi Data</option><option value="ready_activation">Siap Aktivasi</option><option value="activation_failed">Aktivasi Gagal</option></CFormSelect></div>
|
||||
<div><CFormLabel>Catatan</CFormLabel><CFormTextarea v-model="form.notes" rows="4" /></div>
|
||||
</CCardBody></CCard>
|
||||
<CButton color="primary" class="w-100 save-mobile" :disabled="saving || uploading" @click="save">{{ saving ? 'Menyimpan...' : 'Simpan Customer' }}</CButton>
|
||||
</div>
|
||||
</CCol>
|
||||
</CRow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customer-form-page { padding: 0.5rem; }
|
||||
.customer-page-header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; margin-bottom: 1rem; }
|
||||
.form-loading { min-height: 16rem; display: flex; align-items: center; justify-content: center; gap: 0.75rem; }
|
||||
.form-card { border: 1px solid var(--cui-border-color); border-radius: 0.85rem; box-shadow: 0 0.2rem 0.7rem rgba(0, 0, 0, 0.04); overflow: hidden; }
|
||||
.map-shell { overflow: hidden; border: 1px solid var(--cui-border-color); border-radius: 0.75rem; }
|
||||
.map-coordinates { display: grid; grid-template-columns: 1fr 1fr auto; align-items: end; gap: 0.75rem; padding: 0.75rem; background: var(--cui-tertiary-bg); }
|
||||
.gallery-main { position: relative; overflow: hidden; aspect-ratio: 16 / 8; border-radius: 0.75rem; background: var(--cui-tertiary-bg); }
|
||||
.gallery-main img { width: 100%; height: 100%; object-fit: cover; cursor: zoom-in; }
|
||||
.gallery-overlay { position: absolute; inset: auto 0 0; display: flex; align-items: center; gap: 0.5rem; padding: 1rem; color: white; background: linear-gradient(transparent, rgba(0, 0, 0, 0.75)); }
|
||||
.gallery-thumbnails { display: flex; gap: 0.6rem; overflow-x: auto; padding: 0.75rem 0 0.25rem; }
|
||||
.gallery-thumb, .gallery-add { position: relative; width: 5.5rem; height: 4.5rem; flex: 0 0 auto; overflow: hidden; border: 2px solid transparent; border-radius: 0.65rem; background: var(--cui-tertiary-bg); }
|
||||
.gallery-thumb.active { border-color: var(--cui-primary); }
|
||||
.gallery-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.gallery-thumb span { position: absolute; right: 0.2rem; bottom: 0.2rem; padding: 0.05rem 0.25rem; border-radius: 0.25rem; color: white; font-size: 0.65rem; background: var(--cui-primary); }
|
||||
.gallery-add { display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--cui-primary); border-style: dashed; border-color: var(--cui-primary); }
|
||||
.gallery-add strong { font-size: 1.5rem; line-height: 1; }
|
||||
.gallery-add span { font-size: 0.72rem; }
|
||||
.customer-sidebar { position: sticky; top: 5.5rem; }
|
||||
@media (max-width: 1199.98px) { .customer-sidebar { position: static; } }
|
||||
@media (max-width: 767.98px) {
|
||||
.customer-form-page { padding: 0; }
|
||||
.customer-page-header { align-items: flex-start; flex-direction: column; }
|
||||
.customer-page-header > button { display: none; }
|
||||
.map-coordinates { grid-template-columns: 1fr; }
|
||||
.gallery-main { aspect-ratio: 4 / 3; }
|
||||
.save-mobile { position: sticky; bottom: 0.5rem; z-index: 5; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getTenants } from '@/services/tenantService'
|
||||
import {
|
||||
activateCustomer, createCustomer, deactivateCustomer, forceDeleteCustomer,
|
||||
getCustomer, getCustomerOptions, getCustomers, restoreCustomer, trashCustomer, updateCustomer,
|
||||
} from '@/services/customerService'
|
||||
import {
|
||||
getDesaOptions, getKabupatenOptions, getKecamatanOptions, getProvinsiOptions,
|
||||
} from '@/services/wilayahService'
|
||||
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
|
||||
import { getTemporaryFileUrl } from '@/services/fileService'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const scope = computed(() => route.meta.customerScope)
|
||||
const scopeConfig = computed(() => ({
|
||||
orders: ['List Order', 'Pendaftaran dan aktivasi customer'],
|
||||
active: ['Customer Aktif', 'Daftar customer berstatus aktif'],
|
||||
inactive: ['Customer Tidak Aktif', 'Daftar customer yang dinonaktifkan'],
|
||||
unmanaged: ['Customer Unmanage', 'Koneksi aktif yang belum terdaftar sebagai customer'],
|
||||
trash: ['Sampah Customer', 'Customer terhapus yang masih dapat dipulihkan'],
|
||||
})[scope.value])
|
||||
const isMaster = computed(() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin')
|
||||
|
||||
const loading = ref(true), loadedOnce = ref(false), saving = ref(false)
|
||||
const items = ref([]), modal = ref(false), detailModal = ref(false), editingId = ref(null), detail = ref(null)
|
||||
const tenants = ref([]), profiles = ref([]), mikrotiks = ref([])
|
||||
const provinsi = ref([]), kabupaten = ref([]), kecamatan = ref([]), desa = ref([])
|
||||
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
|
||||
const query = ref({ page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' })
|
||||
const form = reactive({})
|
||||
|
||||
const columns = computed(() => [
|
||||
{ key: 'image', label: 'Gambar', sortable: false },
|
||||
{ key: 'customer_code', label: 'ID Customer' },
|
||||
{ key: 'name', label: 'Nama' },
|
||||
{ key: scope.value === 'unmanaged' ? 'external_username' : 'whatsapp_number', label: scope.value === 'unmanaged' ? 'Username' : 'WhatsApp' },
|
||||
{ key: 'package_label', label: 'Paket', sortable: false },
|
||||
{ key: 'stage_label', label: 'Status', sortable: false },
|
||||
])
|
||||
const actions = computed(() => {
|
||||
if (scope.value === 'trash') return [
|
||||
{ key: 'detail', label: 'Detail', color: 'primary' },
|
||||
{ key: 'restore', label: 'Pulihkan', color: 'success' },
|
||||
{ key: 'force-delete', label: 'Hapus Permanen', color: 'danger' },
|
||||
]
|
||||
const result = [{ key: 'detail', label: 'Detail', color: 'primary' }]
|
||||
if (scope.value === 'orders') result.push(
|
||||
{ key: 'edit', label: 'Lengkapi Data', color: 'success' },
|
||||
{ key: 'activate', label: 'Aktivasi', color: 'info' },
|
||||
)
|
||||
if (scope.value === 'active') result.push({ key: 'deactivate', label: 'Nonaktifkan', color: 'warning' })
|
||||
if (scope.value === 'inactive') result.push({ key: 'trash', label: 'Ke Sampah', color: 'danger' })
|
||||
return result
|
||||
})
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
tenant_id: '', customer_code: '', name: '', identity_number: '', email: '',
|
||||
whatsapp_number: '', installation_address: '', provinsi_id: '', kabupaten_id: '',
|
||||
kecamatan_id: '', desa_id: '', latitude: '', longitude: '', package_profile_id: '',
|
||||
nas_mikrotik_id: '', external_username: '', order_stage: 'registration', notes: '',
|
||||
}
|
||||
}
|
||||
function normalize(item) {
|
||||
const stages = { registration: 'Registrasi', data_completion: 'Melengkapi Data', ready_activation: 'Siap Aktivasi', activation_failed: 'Aktivasi Gagal' }
|
||||
return {
|
||||
...item,
|
||||
image: '/customer-default.svg',
|
||||
package_label: item.package_profile?.name || '-',
|
||||
stage_label: scope.value === 'orders' ? stages[item.order_stage] || item.order_stage : ({ active: 'Aktif', inactive: 'Tidak Aktif', unmanaged: 'Unmanage' })[item.status] || item.status,
|
||||
}
|
||||
}
|
||||
async function hydrateCoverImages(rows) {
|
||||
await Promise.allSettled(rows.map(async (item) => {
|
||||
if (!item.cover_image?.uuid) return
|
||||
const response = await getTemporaryFileUrl(item.cover_image.uuid)
|
||||
item.image = response?.data?.url || '/customer-default.svg'
|
||||
}))
|
||||
}
|
||||
function optionRows(response) {
|
||||
return Array.isArray(response?.data) ? response.data : []
|
||||
}
|
||||
async function fetchData(params = query.value) {
|
||||
query.value = { ...query.value, ...params }; loading.value = true
|
||||
try {
|
||||
const response = await getCustomers(scope.value, query.value)
|
||||
const rows = response?.data?.data || []; items.value = rows.map(normalize)
|
||||
hydrateCoverImages(items.value)
|
||||
pagination.value = { current_page: response?.data?.current_page || 1, last_page: response?.data?.last_page || 1, per_page: response?.data?.per_page || 10, total: response?.data?.total || rows.length }
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Gagal memuat customer.') }
|
||||
finally { loading.value = false; loadedOnce.value = true }
|
||||
}
|
||||
async function loadOptions() {
|
||||
try {
|
||||
const calls = [
|
||||
getProvinsiOptions(),
|
||||
getCustomerOptions(),
|
||||
]
|
||||
if (isMaster.value) calls.push(getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' }))
|
||||
const [p, options, tenantResponse] = await Promise.all(calls)
|
||||
provinsi.value = optionRows(p); profiles.value = options?.data?.package_profiles || []; mikrotiks.value = options?.data?.nas_mikrotiks || []
|
||||
tenants.value = tenantResponse?.data?.data || []
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Gagal memuat data pilihan.') }
|
||||
}
|
||||
const tenantProfiles = computed(() => !form.tenant_id ? profiles.value : profiles.value.filter((x) => Number(x.tenant_id) === Number(form.tenant_id)))
|
||||
const tenantNas = computed(() => !form.tenant_id ? mikrotiks.value : mikrotiks.value.filter((x) => Number(x.tenant_id) === Number(form.tenant_id)))
|
||||
async function loadKabupaten(reset = true) {
|
||||
if (reset) Object.assign(form, { kabupaten_id: '', kecamatan_id: '', desa_id: '' })
|
||||
kabupaten.value = form.provinsi_id ? optionRows(await getKabupatenOptions(form.provinsi_id)) : []
|
||||
}
|
||||
async function loadKecamatan(reset = true) {
|
||||
if (reset) Object.assign(form, { kecamatan_id: '', desa_id: '' })
|
||||
kecamatan.value = form.kabupaten_id ? optionRows(await getKecamatanOptions(form.kabupaten_id)) : []
|
||||
}
|
||||
async function loadDesa(reset = true) {
|
||||
if (reset) form.desa_id = ''
|
||||
desa.value = form.kecamatan_id ? optionRows(await getDesaOptions(form.kecamatan_id)) : []
|
||||
}
|
||||
async function onProvinsiChange(event) {
|
||||
form.provinsi_id = event.target.value
|
||||
await loadKabupaten()
|
||||
}
|
||||
async function onKabupatenChange(event) {
|
||||
form.kabupaten_id = event.target.value
|
||||
await loadKecamatan()
|
||||
}
|
||||
async function onKecamatanChange(event) {
|
||||
form.kecamatan_id = event.target.value
|
||||
await loadDesa()
|
||||
}
|
||||
async function openForm(item = null) {
|
||||
return router.push(
|
||||
item?.id
|
||||
? `/customers/orders/${item.id}/edit`
|
||||
: '/customers/orders/create',
|
||||
)
|
||||
}
|
||||
function payload() {
|
||||
return Object.fromEntries(Object.entries(form).map(([key, value]) => [key, value === '' ? null : value]))
|
||||
}
|
||||
async function save() {
|
||||
if (!form.name?.trim()) return showError('Nama customer wajib diisi.')
|
||||
if (isMaster.value && !form.tenant_id) return showError('Tenant wajib dipilih.')
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) await updateCustomer(editingId.value, payload()); else await createCustomer(payload())
|
||||
modal.value = false; showSuccess('Data customer berhasil disimpan.'); await fetchData()
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Gagal menyimpan customer.') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function showDetail(item) {
|
||||
return router.push(`/customers/${scope.value}/${item.id}/detail`)
|
||||
}
|
||||
async function runAction(action, item) {
|
||||
if (action === 'detail') return showDetail(item)
|
||||
if (action === 'edit') return openForm(item)
|
||||
const labels = { activate: 'aktifkan', deactivate: 'nonaktifkan', trash: 'pindahkan ke Sampah', restore: 'pulihkan', 'force-delete': 'hapus permanen' }
|
||||
const result = await showConfirm('Konfirmasi', `Yakin ingin ${labels[action]} customer "${item.name}"?`)
|
||||
if (!result.isConfirmed) return
|
||||
try {
|
||||
if (action === 'activate') await activateCustomer(item.id, { package_profile_id: item.package_profile_id, nas_mikrotik_id: item.nas_mikrotik_id, billing_profile_id: item.billing_profile_id, external_username: item.external_username })
|
||||
if (action === 'deactivate') await deactivateCustomer(item.id)
|
||||
if (action === 'trash') await trashCustomer(item.id)
|
||||
if (action === 'restore') await restoreCustomer(item.id)
|
||||
if (action === 'force-delete') await forceDeleteCustomer(item.id)
|
||||
showSuccess('Status customer berhasil diperbarui.'); await fetchData()
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Proses customer gagal.') }
|
||||
}
|
||||
watch(scope, () => { items.value = []; loadedOnce.value = false; query.value.page = 1; fetchData() })
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CRow class="brdvx-page-row g-0 m-0"><CCol :xs="12" class="brdvx-page-column p-0"><div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div><h5 class="mb-0 fw-semibold">{{ scopeConfig[0] }}</h5><small class="text-body-secondary">{{ scopeConfig[1] }}</small></div>
|
||||
<CButton v-if="scope === 'orders'" color="primary" size="sm" @click="openForm()">+ Daftarkan Customer</CButton>
|
||||
</div>
|
||||
<div v-if="loading && !loadedOnce" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div>
|
||||
<BaseResponsiveDataView v-else :columns="columns" :items="items" server-side :pagination="pagination" :loading="loading" :actions="actions" row-key="id" @action-click="({ action, item }) => runAction(action, item)" @query-change="fetchData" />
|
||||
</div></CCol></CRow>
|
||||
|
||||
<CModal :visible="modal" @close="modal = false" size="xl">
|
||||
<CModalHeader dismiss @close="modal = false"><CModalTitle>{{ editingId ? 'Lengkapi Data' : 'Pendaftaran' }} Customer</CModalTitle></CModalHeader>
|
||||
<CModalBody><CRow class="g-3">
|
||||
<CCol v-if="isMaster" :md="4"><CFormLabel>Tenant</CFormLabel><CFormSelect v-model="form.tenant_id"><option value="">Pilih tenant</option><option v-for="x in tenants" :key="x.id" :value="x.id">{{ x.tenant_name }}</option></CFormSelect></CCol>
|
||||
<CCol :md="4"><CFormLabel>ID Customer</CFormLabel><CFormInput v-model="form.customer_code" placeholder="Otomatis jika kosong" /></CCol>
|
||||
<CCol :md="4"><CFormLabel>Nama</CFormLabel><CFormInput v-model="form.name" /></CCol>
|
||||
<CCol :md="4"><CFormLabel>NIK/Identitas</CFormLabel><CFormInput v-model="form.identity_number" /></CCol>
|
||||
<CCol :md="4"><CFormLabel>Email</CFormLabel><CFormInput v-model="form.email" type="email" /></CCol>
|
||||
<CCol :md="4"><CFormLabel>WhatsApp</CFormLabel><CFormInput v-model="form.whatsapp_number" /></CCol>
|
||||
<CCol :xs="12"><CFormLabel>Alamat Pemasangan</CFormLabel><CFormTextarea v-model="form.installation_address" rows="2" /></CCol>
|
||||
<CCol :md="3"><CFormLabel>Provinsi</CFormLabel><CFormSelect v-model="form.provinsi_id" @change="onProvinsiChange"><option value="">Pilih</option><option v-for="x in provinsi" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
|
||||
<CCol :md="3"><CFormLabel>Kabupaten</CFormLabel><CFormSelect v-model="form.kabupaten_id" @change="onKabupatenChange"><option value="">Pilih</option><option v-for="x in kabupaten" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
|
||||
<CCol :md="3"><CFormLabel>Kecamatan</CFormLabel><CFormSelect v-model="form.kecamatan_id" @change="onKecamatanChange"><option value="">Pilih</option><option v-for="x in kecamatan" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
|
||||
<CCol :md="3"><CFormLabel>Desa</CFormLabel><CFormSelect v-model="form.desa_id"><option value="">Pilih</option><option v-for="x in desa" :key="x.id" :value="x.id">{{ x.nama }}</option></CFormSelect></CCol>
|
||||
<CCol :md="4"><CFormLabel>Profile Paket</CFormLabel><CFormSelect v-model="form.package_profile_id"><option value="">Pilih paket</option><option v-for="x in tenantProfiles" :key="x.id" :value="x.id">{{ x.name }}</option></CFormSelect></CCol>
|
||||
<CCol :md="4"><CFormLabel>NAS Mikrotik</CFormLabel><CFormSelect v-model="form.nas_mikrotik_id"><option value="">Pilih NAS</option><option v-for="x in tenantNas" :key="x.id" :value="x.id">{{ x.name }}</option></CFormSelect></CCol>
|
||||
<CCol :md="4"><CFormLabel>Username Eksternal</CFormLabel><CFormInput v-model="form.external_username" /></CCol>
|
||||
<CCol :md="3"><CFormLabel>Latitude</CFormLabel><CFormInput v-model="form.latitude" type="number" step="any" /></CCol>
|
||||
<CCol :md="3"><CFormLabel>Longitude</CFormLabel><CFormInput v-model="form.longitude" type="number" step="any" /></CCol>
|
||||
<CCol :md="6"><CFormLabel>Tahap Order</CFormLabel><CFormSelect v-model="form.order_stage"><option value="registration">Registrasi</option><option value="data_completion">Melengkapi Data</option><option value="ready_activation">Siap Aktivasi</option><option value="activation_failed">Aktivasi Gagal</option></CFormSelect></CCol>
|
||||
<CCol :xs="12"><CFormLabel>Catatan</CFormLabel><CFormTextarea v-model="form.notes" rows="2" /></CCol>
|
||||
</CRow></CModalBody>
|
||||
<CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="save">{{ saving ? 'Menyimpan...' : 'Simpan' }}</CButton></CModalFooter>
|
||||
</CModal>
|
||||
|
||||
<CModal :visible="detailModal" @close="detailModal = false" size="lg"><CModalHeader dismiss @close="detailModal = false"><CModalTitle>Detail Customer</CModalTitle></CModalHeader>
|
||||
<CModalBody v-if="detail"><CRow class="g-3"><CCol :md="6"><strong>{{ detail.customer_code }} — {{ detail.name }}</strong><div>{{ detail.email || '-' }}</div><div>{{ detail.whatsapp_number || '-' }}</div></CCol><CCol :md="6"><div class="text-body-secondary">Alamat Pemasangan</div><div>{{ detail.full_installation_address || '-' }}</div></CCol><CCol :md="6"><div class="text-body-secondary">Paket</div><div>{{ detail.package_profile?.name || '-' }}</div></CCol><CCol :md="6"><div class="text-body-secondary">NAS</div><div>{{ detail.nas_mikrotik?.name || '-' }}</div></CCol></CRow></CModalBody>
|
||||
<CModalFooter><CButton color="secondary" @click="detailModal = false">Tutup</CButton></CModalFooter></CModal>
|
||||
</template>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<CRow>
|
||||
<CCol :xs="12">
|
||||
<DocsComponents href="forms/chip-input/" />
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Vue Chip Input</strong> <small>Basic example</small>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<p class="text-body-secondary small">
|
||||
Chip input lets users enter multiple values in one field. Use the
|
||||
<code>CChipInput</code> component with props like <code>placeholder</code> and
|
||||
<code>defaultValue</code>.
|
||||
</p>
|
||||
<DocsExample href="forms/chip-input/#basic-example">
|
||||
<CChipInput
|
||||
label="Skills:"
|
||||
name="skills"
|
||||
placeholder="Add a skill..."
|
||||
:defaultValue="['JavaScript', 'TypeScript', 'Accessibility']"
|
||||
/>
|
||||
</DocsExample>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Vue Chip Input</strong> <small>Variants</small>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<p class="text-body-secondary small">
|
||||
Use the <code>chipClassName</code> prop to apply contextual chip styles, which is handy
|
||||
for labels, issue types, or priorities.
|
||||
</p>
|
||||
<DocsExample href="forms/chip-input/#variants">
|
||||
<CChipInput
|
||||
name="issues"
|
||||
placeholder="Add label..."
|
||||
:defaultValue="['Feature', 'Approved', 'Needs review', 'Blocking']"
|
||||
:chipClassName="getChipClassName"
|
||||
/>
|
||||
</DocsExample>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Vue Chip Input</strong> <small>Sizes</small>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<p class="text-body-secondary small">
|
||||
Use the <code>size</code> prop with <code>"sm"</code> or <code>"lg"</code> to align the
|
||||
field with surrounding form controls.
|
||||
</p>
|
||||
<DocsExample href="forms/chip-input/#sizes">
|
||||
<CChipInput
|
||||
label="Small"
|
||||
size="sm"
|
||||
placeholder="Add small tag..."
|
||||
:defaultValue="['HTML']"
|
||||
class="mb-3"
|
||||
/>
|
||||
<CChipInput
|
||||
label="Default"
|
||||
placeholder="Add default tag..."
|
||||
:defaultValue="['JavaScript']"
|
||||
class="mb-3"
|
||||
/>
|
||||
<CChipInput
|
||||
label="Large"
|
||||
size="lg"
|
||||
placeholder="Add large tag..."
|
||||
:defaultValue="['TypeScript']"
|
||||
/>
|
||||
</DocsExample>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
|
||||
<CCol :xs="12">
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Vue Chip Input</strong> <small>Empty state and label</small>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<p class="text-body-secondary small">
|
||||
You can start with an empty field or use a separate <code>CFormLabel</code> for clearer
|
||||
form structure and accessibility.
|
||||
</p>
|
||||
<DocsExample href="forms/chip-input/#empty-state">
|
||||
<CChipInput name="tags" placeholder="Start typing tags..." class="mb-3" />
|
||||
<div class="mb-0">
|
||||
<CFormLabel for="techStackInput">Tech stack</CFormLabel>
|
||||
<CChipInput
|
||||
id="techStackInput"
|
||||
name="techStack"
|
||||
placeholder="Add package..."
|
||||
:defaultValue="['Vue', 'Vite']"
|
||||
/>
|
||||
<CFormText>Press Enter or comma to add a value.</CFormText>
|
||||
</div>
|
||||
</DocsExample>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Vue Chip Input</strong> <small>Disabled and readonly</small>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<p class="text-body-secondary small">
|
||||
Use the <code>disabled</code> prop to block interaction entirely, or
|
||||
<code>readOnly</code> to keep values visible while preventing changes.
|
||||
</p>
|
||||
<DocsExample href="forms/chip-input/#disabled">
|
||||
<CChipInput
|
||||
disabled
|
||||
:removable="false"
|
||||
placeholder="Input disabled"
|
||||
:defaultValue="['Read only', 'Locked']"
|
||||
class="mb-3"
|
||||
/>
|
||||
<CChipInput
|
||||
readOnly
|
||||
placeholder="Read-only values"
|
||||
:defaultValue="['JavaScript', 'TypeScript']"
|
||||
/>
|
||||
</DocsExample>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Vue Chip Input</strong> <small>Form-friendly examples</small>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<p class="text-body-secondary small">
|
||||
The component integrates well with ordinary forms, including helper text and predefined
|
||||
values.
|
||||
</p>
|
||||
<DocsExample href="forms/chip-input/#with-label">
|
||||
<form class="row g-3">
|
||||
<div class="col-12">
|
||||
<CFormLabel for="recipientsInput">Recipients</CFormLabel>
|
||||
<CChipInput
|
||||
id="recipientsInput"
|
||||
name="recipients"
|
||||
placeholder="Add email..."
|
||||
:defaultValue="['olivia@coreui.io', 'ethan@coreui.io']"
|
||||
/>
|
||||
<CFormText>Add one address at a time and press Enter.</CFormText>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<CFormLabel for="categoriesInput">Categories</CFormLabel>
|
||||
<CChipInput
|
||||
id="categoriesInput"
|
||||
name="categories"
|
||||
placeholder="Add category..."
|
||||
:defaultValue="['Product', 'Design', 'Docs']"
|
||||
chipClassName="chip-outline"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</DocsExample>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
</CRow>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ChipInput',
|
||||
setup() {
|
||||
const getChipClassName = (value) => {
|
||||
const colorMap = {
|
||||
Feature: 'chip-primary',
|
||||
Approved: 'chip-success',
|
||||
'Needs review': 'chip-warning',
|
||||
Blocking: 'chip-danger',
|
||||
}
|
||||
return colorMap[value] || ''
|
||||
}
|
||||
|
||||
return {
|
||||
getChipClassName,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,353 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
getMenuGroupAvailableById,
|
||||
getMenuGroupById,
|
||||
syncMenuGroupMenus,
|
||||
} from '@/services/menuGroupService'
|
||||
import { showError, showSuccess } from '@/utils/swal.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const menuGroup = ref(null)
|
||||
const menuPermissions = ref([])
|
||||
|
||||
const permissionKeys = [
|
||||
'can_view',
|
||||
'can_create',
|
||||
'can_update',
|
||||
'can_delete',
|
||||
'can_approve',
|
||||
'can_export',
|
||||
]
|
||||
const permissionLabels = {
|
||||
can_view: 'View',
|
||||
can_create: 'Create',
|
||||
can_update: 'Update',
|
||||
can_delete: 'Delete',
|
||||
can_approve: 'Approve',
|
||||
can_export: 'Export',
|
||||
}
|
||||
|
||||
const isMasterAdmin = computed(
|
||||
() =>
|
||||
authStore.user?.is_master === true ||
|
||||
authStore.user?.access_level === 'master_admin',
|
||||
)
|
||||
const canManageGroup = computed(
|
||||
() => !!menuGroup.value && (isMasterAdmin.value || !menuGroup.value.is_system),
|
||||
)
|
||||
|
||||
const menuSections = computed(() => {
|
||||
const childrenByParent = new Map()
|
||||
|
||||
menuPermissions.value.forEach((menu) => {
|
||||
const parentId = menu.parent_id ?? null
|
||||
if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, [])
|
||||
childrenByParent.get(parentId).push(menu)
|
||||
})
|
||||
|
||||
const flattenChildren = (parentId, depth = 1) =>
|
||||
(childrenByParent.get(parentId) || []).flatMap((menu) => [
|
||||
Object.assign(menu, { depth }),
|
||||
...flattenChildren(menu.menu_id, depth + 1),
|
||||
])
|
||||
|
||||
return (childrenByParent.get(null) || []).map((root) => ({
|
||||
root,
|
||||
descendants: flattenChildren(root.menu_id),
|
||||
}))
|
||||
})
|
||||
|
||||
function normalizeMenus(rawMenus) {
|
||||
const accessibleMenus = isMasterAdmin.value
|
||||
? rawMenus
|
||||
: rawMenus.filter((menu) =>
|
||||
permissionKeys.some((key) => menu.available_permissions?.[key] === true),
|
||||
)
|
||||
const accessibleIds = new Set(accessibleMenus.map((menu) => menu.menu_id))
|
||||
const parentIds = new Set(
|
||||
accessibleMenus.map((menu) => menu.parent_id).filter((id) => accessibleIds.has(id)),
|
||||
)
|
||||
|
||||
return accessibleMenus.map((menu) => {
|
||||
const permissions = menu.permissions || {}
|
||||
const hasChildren = parentIds.has(menu.menu_id)
|
||||
const availablePermissions = permissionKeys.reduce((result, key) => {
|
||||
result[key] = menu.available_permissions?.[key] !== false
|
||||
return result
|
||||
}, {})
|
||||
|
||||
return {
|
||||
menu_id: menu.menu_id,
|
||||
parent_id: accessibleIds.has(menu.parent_id) ? menu.parent_id : null,
|
||||
has_children: hasChildren,
|
||||
name: menu.name || menu.label || `Menu #${menu.menu_id}`,
|
||||
available_permissions: availablePermissions,
|
||||
can_view: availablePermissions.can_view && !!permissions.can_view,
|
||||
can_create: !hasChildren && availablePermissions.can_create && !!permissions.can_create,
|
||||
can_update: !hasChildren && availablePermissions.can_update && !!permissions.can_update,
|
||||
can_delete: !hasChildren && availablePermissions.can_delete && !!permissions.can_delete,
|
||||
can_approve: !hasChildren && availablePermissions.can_approve && !!permissions.can_approve,
|
||||
can_export: !hasChildren && availablePermissions.can_export && !!permissions.can_export,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [groupResponse, menuResponse] = await Promise.all([
|
||||
getMenuGroupById(route.params.id),
|
||||
getMenuGroupAvailableById(route.params.id),
|
||||
])
|
||||
|
||||
menuGroup.value = groupResponse?.data || null
|
||||
const rawMenus = Array.isArray(menuResponse?.data?.menus) ? menuResponse.data.menus : []
|
||||
menuPermissions.value = normalizeMenus(rawMenus)
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || 'Gagal memuat detail menu group.')
|
||||
router.replace('/group-menus')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function availablePermissionKeys(menu) {
|
||||
const keys = menu.has_children ? ['can_view'] : permissionKeys
|
||||
return keys.filter((key) => menu.available_permissions?.[key])
|
||||
}
|
||||
|
||||
function isMenuChecked(menu) {
|
||||
const keys = availablePermissionKeys(menu)
|
||||
return keys.length > 0 && keys.every((key) => menu[key])
|
||||
}
|
||||
|
||||
function toggleMenu(menu, checked) {
|
||||
availablePermissionKeys(menu).forEach((key) => {
|
||||
menu[key] = checked
|
||||
})
|
||||
}
|
||||
|
||||
async function savePermissions() {
|
||||
if (!canManageGroup.value || !menuPermissions.value.length) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await syncMenuGroupMenus(route.params.id, {
|
||||
menus: menuPermissions.value.map((menu) => ({
|
||||
menu_id: menu.menu_id,
|
||||
can_view: !!menu.can_view,
|
||||
can_create: menu.has_children ? false : !!menu.can_create,
|
||||
can_update: menu.has_children ? false : !!menu.can_update,
|
||||
can_delete: menu.has_children ? false : !!menu.can_delete,
|
||||
can_approve: menu.has_children ? false : !!menu.can_approve,
|
||||
can_export: menu.has_children ? false : !!menu.can_export,
|
||||
})),
|
||||
})
|
||||
showSuccess('Permission menu group berhasil disimpan.')
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || 'Gagal menyimpan permission menu group.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container-fluid px-2">
|
||||
<div class="permissions-header">
|
||||
<div>
|
||||
<!-- <CButton color="secondary" variant="ghost" size="sm" class="mb-1" @click="router.push('/group-menus')">
|
||||
← Kembali
|
||||
</CButton> -->
|
||||
<h5 class="mb-1 fw-semibold">Permission Menu Group</h5>
|
||||
<div class="small text-body-secondary">
|
||||
{{ menuGroup?.name || 'Memuat menu group...' }}
|
||||
<span v-if="menuGroup?.tenant?.tenant_name"> · {{ menuGroup.tenant.tenant_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CButton
|
||||
v-if="canManageGroup"
|
||||
color="primary"
|
||||
size="sm"
|
||||
:disabled="loading || saving || !menuPermissions.length"
|
||||
@click="savePermissions"
|
||||
>
|
||||
{{ saving ? 'Menyimpan...' : 'Simpan Permission' }}
|
||||
</CButton>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<CSpinner color="primary" size="sm" />
|
||||
<span>Memuat data menu...</span>
|
||||
</div>
|
||||
|
||||
<CAlert v-else-if="!canManageGroup" color="warning">
|
||||
Menu group sistem hanya dapat diubah oleh Master Admin.
|
||||
</CAlert>
|
||||
|
||||
<CRow v-else-if="menuSections.length" class="g-2">
|
||||
<CCol v-for="section in menuSections" :key="section.root.menu_id" :xs="6" :md="4">
|
||||
<CCard class="menu-section-card">
|
||||
<CCardHeader class="menu-section-header">
|
||||
<CFormCheck
|
||||
:model-value="isMenuChecked(section.root)"
|
||||
@change="toggleMenu(section.root, $event.target.checked)"
|
||||
/>
|
||||
<span>{{ section.root.name }}</span>
|
||||
</CCardHeader>
|
||||
<CCardBody class="p-0">
|
||||
<div v-if="!section.descendants.length" class="root-permission-list">
|
||||
<CFormCheck
|
||||
v-for="key in availablePermissionKeys(section.root)"
|
||||
:key="key"
|
||||
v-model="section.root[key]"
|
||||
:label="permissionLabels[key]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="menu in section.descendants"
|
||||
:key="menu.menu_id"
|
||||
class="menu-permission-item"
|
||||
:style="{ '--menu-depth': menu.depth }"
|
||||
>
|
||||
<div class="menu-name-row">
|
||||
<span class="tree-connector" aria-hidden="true"></span>
|
||||
<CFormCheck
|
||||
:model-value="isMenuChecked(menu)"
|
||||
@change="toggleMenu(menu, $event.target.checked)"
|
||||
/>
|
||||
<span class="menu-name">{{ menu.name }}</span>
|
||||
</div>
|
||||
<div class="permission-list">
|
||||
<CFormCheck
|
||||
v-for="key in availablePermissionKeys(menu)"
|
||||
:key="key"
|
||||
v-model="menu[key]"
|
||||
:label="permissionLabels[key]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
</CRow>
|
||||
|
||||
<div v-else class="empty-state">Data menu tidak tersedia.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.permissions-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
min-height: 10rem;
|
||||
color: var(--cui-secondary-color);
|
||||
}
|
||||
|
||||
.menu-section-card {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
min-height: 2.65rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.925rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu-permission-item {
|
||||
padding: 0.55rem 0.7rem 0.55rem calc(0.7rem + (var(--menu-depth) * 0.65rem));
|
||||
border-top: 1px solid var(--cui-border-color);
|
||||
}
|
||||
|
||||
.menu-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tree-connector {
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 1px solid var(--cui-border-color);
|
||||
border-left: 1px solid var(--cui-border-color);
|
||||
}
|
||||
|
||||
.menu-name {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.permission-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.85rem;
|
||||
padding: 0.35rem 0 0 2rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.root-permission-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.3rem 0.65rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.root-permission-list :deep(.form-check) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.permission-list :deep(.form-check) {
|
||||
min-width: 4.7rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.permissions-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.permissions-header > button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.root-permission-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.permission-list {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,433 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import { useMenuGroupStore } from '@/stores/menuGroup'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
getMenuGroups,
|
||||
createMenuGroup,
|
||||
getMenuGroupById,
|
||||
updateMenuGroup,
|
||||
deleteMenuGroup,
|
||||
} from '@/services/menuGroupService'
|
||||
import { formatTanggal } from '@/utils/tglindo.js'
|
||||
import { showConfirm, showSuccess, showError } from '@/utils/swal.js'
|
||||
import { getTenants } from '@/services/tenantService'
|
||||
|
||||
const store = useMenuGroupStore()
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const loadedOnce = ref(false)
|
||||
const saving = ref(false)
|
||||
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
|
||||
const currentQuery = ref({ page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' })
|
||||
|
||||
const modal = ref(false)
|
||||
const detailModal = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editingIndex = ref(-1)
|
||||
const deleteIndex = ref(-1)
|
||||
const tenantOptions = ref([])
|
||||
const tenantOptionsLoaded = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
tenant_id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
const detailItem = ref(null)
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
tenant_id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
is_active: true,
|
||||
}
|
||||
}
|
||||
|
||||
function responseRows(response) {
|
||||
if (Array.isArray(response?.data?.data)) return response.data.data
|
||||
if (Array.isArray(response?.data)) return response.data
|
||||
return []
|
||||
}
|
||||
|
||||
async function loadTenantOptions() {
|
||||
if (!isMasterAdmin.value || tenantOptionsLoaded.value) return
|
||||
try {
|
||||
const response = await getTenants({
|
||||
per_page: 100,
|
||||
sort_by: 'tenant_name',
|
||||
sort_direction: 'asc',
|
||||
})
|
||||
tenantOptions.value = responseRows(response)
|
||||
tenantOptionsLoaded.value = true
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || 'Gagal memuat pilihan tenant.')
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllData(params = currentQuery.value) {
|
||||
currentQuery.value = { ...currentQuery.value, ...params }
|
||||
params = currentQuery.value
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getMenuGroups(params)
|
||||
const rows = Array.isArray(res?.data?.data)
|
||||
? res.data.data
|
||||
: Array.isArray(res?.data)
|
||||
? res.data
|
||||
: []
|
||||
store.setMenuGroups(rows)
|
||||
const meta = res?.data && !Array.isArray(res.data) ? res.data : {}
|
||||
pagination.value = {
|
||||
current_page: meta.current_page || params.page || 1,
|
||||
last_page: meta.last_page || 1,
|
||||
per_page: meta.per_page || params.per_page || 10,
|
||||
total: meta.total || rows.length,
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err?.response?.data?.message || 'Gagal memuat menu groups: ' + err.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadedOnce.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAllData()
|
||||
})
|
||||
|
||||
async function openAddModal() {
|
||||
isEdit.value = false
|
||||
editingIndex.value = -1
|
||||
Object.assign(form, emptyForm())
|
||||
await loadTenantOptions()
|
||||
modal.value = true
|
||||
}
|
||||
|
||||
async function openEditModal(item) {
|
||||
if (!canManageGroup(item)) {
|
||||
showError('Menu group sistem hanya dapat diubah oleh Master Admin.')
|
||||
return
|
||||
}
|
||||
|
||||
const idx = store.menuGroups.findIndex((m) => m.id === item.id)
|
||||
if (idx === -1) return
|
||||
await loadTenantOptions()
|
||||
isEdit.value = true
|
||||
editingIndex.value = idx
|
||||
Object.assign(form, {
|
||||
tenant_id: item.tenant_id ? String(item.tenant_id) : '',
|
||||
name: item.name || '',
|
||||
description: item.description || '',
|
||||
is_active: !!item.is_active,
|
||||
})
|
||||
modal.value = true
|
||||
}
|
||||
|
||||
async function openDetail(item) {
|
||||
try {
|
||||
const res = await getMenuGroupById(item.id)
|
||||
const payload = Array.isArray(res?.data) ? res.data[0] : res?.data
|
||||
detailItem.value = payload || null
|
||||
detailModal.value = true
|
||||
} catch (err) {
|
||||
showError(err?.response?.data?.message || 'Gagal memuat detail: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function openDeleteModal(item) {
|
||||
if (!canManageGroup(item)) {
|
||||
showError('Menu group sistem hanya dapat dihapus oleh Master Admin.')
|
||||
return
|
||||
}
|
||||
|
||||
const idx = store.menuGroups.findIndex((m) => m.id === item.id)
|
||||
if (idx === -1) return
|
||||
|
||||
const result = await showConfirm(
|
||||
'Konfirmasi Hapus',
|
||||
`Apakah Anda yakin ingin menghapus group menu "${item.name}"?`,
|
||||
)
|
||||
if (!result.isConfirmed) return
|
||||
|
||||
deleteIndex.value = idx
|
||||
await removeItem(item.id)
|
||||
}
|
||||
|
||||
async function createItem() {
|
||||
saving.value = true
|
||||
try {
|
||||
const body = {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
is_active: !!form.is_active,
|
||||
}
|
||||
if (isMasterAdmin.value) {
|
||||
body.tenant_id = form.tenant_id ? Number(form.tenant_id) : null
|
||||
}
|
||||
|
||||
const res = await createMenuGroup(body)
|
||||
const created = Array.isArray(res?.data) ? res.data[0] : res?.data
|
||||
store.addMenuGroup(created || body)
|
||||
|
||||
modal.value = false
|
||||
showSuccess('Data berhasil disimpan')
|
||||
await fetchAllData()
|
||||
} catch (err) {
|
||||
showError(err?.response?.data?.message || 'Gagal menyimpan data: ' + err.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateItem(id) {
|
||||
saving.value = true
|
||||
try {
|
||||
const body = {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
is_active: !!form.is_active,
|
||||
}
|
||||
if (isMasterAdmin.value) {
|
||||
body.tenant_id = form.tenant_id ? Number(form.tenant_id) : null
|
||||
}
|
||||
|
||||
const res = await updateMenuGroup(id, body)
|
||||
const updated = Array.isArray(res?.data) ? res.data[0] : res?.data
|
||||
store.updateMenuGroup(editingIndex.value, updated || { id, ...body })
|
||||
|
||||
modal.value = false
|
||||
showSuccess('Data berhasil diupdate')
|
||||
await fetchAllData()
|
||||
} catch (err) {
|
||||
showError(err?.response?.data?.message || 'Gagal mengupdate data: ' + err.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(id) {
|
||||
try {
|
||||
await deleteMenuGroup(id)
|
||||
store.deleteMenuGroup(deleteIndex.value)
|
||||
showSuccess('Data berhasil dihapus')
|
||||
await fetchAllData()
|
||||
} catch (err) {
|
||||
showError(err?.response?.data?.message || 'Gagal menghapus data: ' + err.message)
|
||||
} finally {
|
||||
deleteIndex.value = -1
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nama' },
|
||||
{ key: 'description_label', label: 'Deskripsi', sortKey: 'description' },
|
||||
{ key: 'scope_label', label: 'Scope', sortable: false },
|
||||
{ key: 'type_label', label: 'Tipe', sortKey: 'is_system' },
|
||||
{ key: 'status_label', label: 'Status', sortKey: 'is_active' },
|
||||
]
|
||||
|
||||
const tableActions = [
|
||||
{ key: 'detail', label: 'Detail', color: 'primary' },
|
||||
{ key: 'detail-menu', label: 'Detail Menu', color: 'secondary' },
|
||||
{ key: 'edit', label: 'Edit', color: 'success' },
|
||||
{ key: 'delete', label: 'Hapus', color: 'danger' },
|
||||
]
|
||||
|
||||
const menuGroupItems = computed(() =>
|
||||
store.menuGroups.map((item) => ({
|
||||
...item,
|
||||
description_label: item.description || '-',
|
||||
scope_label:
|
||||
item.tenant?.tenant_name || (item.tenant_id ? `Tenant #${item.tenant_id}` : 'Global'),
|
||||
type_label: item.is_system ? 'Sistem' : 'Tenant',
|
||||
status_label: item.is_active ? 'Aktif' : 'Nonaktif',
|
||||
})),
|
||||
)
|
||||
|
||||
const isMasterAdmin = computed(
|
||||
() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin',
|
||||
)
|
||||
|
||||
const activeTenant = computed(() => {
|
||||
const memberships = authStore.user?.user_tenants || []
|
||||
return memberships.find((row) => row.is_default)?.tenant || memberships[0]?.tenant || null
|
||||
})
|
||||
|
||||
function canManageGroup(item) {
|
||||
return !!item && (isMasterAdmin.value || !item.is_system)
|
||||
}
|
||||
|
||||
function onActionClick({ action, item }) {
|
||||
if (action === 'detail') openDetail(item)
|
||||
if (action === 'detail-menu') router.push(`/group-menus/${item.id}/menus`)
|
||||
if (action === 'edit') openEditModal(item)
|
||||
if (action === 'delete') openDeleteModal(item)
|
||||
}
|
||||
|
||||
async function saveItem() {
|
||||
if (!form.name.trim()) {
|
||||
showError('Nama group menu wajib diisi.')
|
||||
return
|
||||
}
|
||||
|
||||
if (isEdit.value && editingIndex.value >= 0) {
|
||||
const item = store.menuGroups[editingIndex.value]
|
||||
await updateItem(item.id)
|
||||
} else {
|
||||
await createItem()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CRow class="brdvx-page-row g-0 m-0">
|
||||
<CCol :xs="12" class="brdvx-page-column p-0">
|
||||
<div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<h5 class="mb-0 fw-semibold">Group Menus</h5>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<CButton color="primary" size="sm" variant="outline" :disabled="loading" @click="fetchAllData()">
|
||||
{{ loading ? 'Loading...' : 'Refresh' }}
|
||||
</CButton>
|
||||
<CButton color="primary" size="sm" @click="openAddModal">+ Tambah Group Menu</CButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !loadedOnce" class="brdvx-loading-state" role="status" aria-live="polite">
|
||||
<CSpinner color="primary" />
|
||||
<p class="brdvx-loading-text">Memuat data...</p>
|
||||
</div>
|
||||
|
||||
<BaseResponsiveDataView
|
||||
v-else
|
||||
:columns="columns"
|
||||
:items="menuGroupItems"
|
||||
server-side
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
initial-sort-by="id"
|
||||
initial-sort-direction="desc"
|
||||
:actions="tableActions"
|
||||
row-key="id"
|
||||
@action-click="onActionClick"
|
||||
@query-change="fetchAllData"
|
||||
/>
|
||||
</div>
|
||||
</CCol>
|
||||
</CRow>
|
||||
|
||||
<CModal :visible="modal" @close="modal = false" alignment="center">
|
||||
<CModalHeader dismiss @close="modal = false">
|
||||
<CModalTitle>{{ isEdit ? 'Edit Group Menu' : 'Tambah Group Menu' }}</CModalTitle>
|
||||
</CModalHeader>
|
||||
<CModalBody>
|
||||
<CForm>
|
||||
<div v-if="isMasterAdmin" class="mb-3">
|
||||
<CFormLabel>Scope Tenant</CFormLabel>
|
||||
<CFormSelect v-model="form.tenant_id">
|
||||
<option value="">Global / Sistem</option>
|
||||
<option v-for="tenant in tenantOptions" :key="tenant.id" :value="String(tenant.id)">
|
||||
{{ tenant.tenant_code }} — {{ tenant.tenant_name }}
|
||||
</option>
|
||||
</CFormSelect>
|
||||
<div class="form-text">
|
||||
Pilih Global untuk group sistem, atau tenant untuk group khusus tenant.
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="alert alert-info py-2">
|
||||
Group akan dibuat untuk tenant
|
||||
<strong>{{ activeTenant?.tenant_name || 'aktif' }}</strong>.
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Nama</CFormLabel>
|
||||
<CFormInput v-model="form.name" placeholder="Contoh: Master Data" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Deskripsi</CFormLabel>
|
||||
<CFormTextarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
placeholder="Deskripsi group menu (opsional)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<CFormCheck v-model="form.is_active" label="Aktif" />
|
||||
</div>
|
||||
</CForm>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="modal = false" :disabled="saving">Batal</CButton>
|
||||
<CButton color="primary" @click="saveItem" :disabled="saving">
|
||||
<span v-if="saving">Menyimpan...</span>
|
||||
<span v-else>Simpan</span>
|
||||
</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
|
||||
<CModal :visible="detailModal" @close="detailModal = false" alignment="center">
|
||||
<CModalHeader dismiss @close="detailModal = false">
|
||||
<CModalTitle>Detail Group Menu</CModalTitle>
|
||||
</CModalHeader>
|
||||
<CModalBody>
|
||||
<CTable bordered small v-if="detailItem">
|
||||
<CTableBody>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell style="width: 40%">ID</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.id }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Nama</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.name }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Deskripsi</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.description || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Scope</CTableHeaderCell>
|
||||
<CTableDataCell>
|
||||
{{
|
||||
detailItem.tenant?.tenant_name ||
|
||||
(detailItem.tenant_id ? `Tenant #${detailItem.tenant_id}` : 'Global')
|
||||
}}
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Tipe</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.is_system ? 'Sistem' : 'Tenant' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Status</CTableHeaderCell>
|
||||
<CTableDataCell>
|
||||
<CBadge :color="detailItem.is_active ? 'success' : 'secondary'">
|
||||
{{ detailItem.is_active ? 'Aktif' : 'Nonaktif' }}
|
||||
</CBadge>
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Created At</CTableHeaderCell>
|
||||
<CTableDataCell>{{ formatTanggal(detailItem.created_at) }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Updated At</CTableHeaderCell>
|
||||
<CTableDataCell>{{ formatTanggal(detailItem.updated_at) }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
</CTableBody>
|
||||
</CTable>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="detailModal = false">Tutup</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getTenants } from '@/services/tenantService'
|
||||
import {
|
||||
createNasResource,
|
||||
deleteNasResource,
|
||||
getNasResource,
|
||||
getNasResources,
|
||||
updateNasResource,
|
||||
} from '@/services/nasService'
|
||||
import { showConfirm, showError, showSuccess } from '@/utils/swal.js'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const resource = computed(() => route.meta.nasResource)
|
||||
|
||||
const configs = {
|
||||
mikrotik: {
|
||||
title: 'Mikrotik',
|
||||
nameLabel: 'Nama Mikrotik',
|
||||
columns: [
|
||||
{ key: 'name', label: 'Nama' },
|
||||
{ key: 'connection_type_label', label: 'Koneksi', sortKey: 'connection_type' },
|
||||
{ key: 'host', label: 'Host' },
|
||||
{ key: 'status_label', label: 'Status', sortKey: 'status' },
|
||||
],
|
||||
fields: [
|
||||
{ key: 'host', label: 'Host / IP', required: true },
|
||||
{ key: 'api_port', label: 'API Port', type: 'number', show: (f) => f.connection_type === 'api' },
|
||||
{ key: 'api_username', label: 'API Username', show: (f) => f.connection_type === 'api' },
|
||||
{ key: 'api_password', label: 'API Password', type: 'password', secret: true, show: (f) => f.connection_type === 'api' },
|
||||
{ key: 'radius_auth_port', label: 'RADIUS Auth Port', type: 'number', show: (f) => f.connection_type === 'radius' },
|
||||
{ key: 'radius_accounting_port', label: 'RADIUS Accounting Port', type: 'number', show: (f) => f.connection_type === 'radius' },
|
||||
{ key: 'radius_secret', label: 'RADIUS Secret', type: 'password', secret: true, show: (f) => f.connection_type === 'radius' },
|
||||
{ key: 'timeout', label: 'Timeout (detik)', type: 'number' },
|
||||
],
|
||||
},
|
||||
'package-profile': {
|
||||
title: 'Profile Paket',
|
||||
nameLabel: 'Nama Paket',
|
||||
columns: [
|
||||
{ key: 'name', label: 'Nama' },
|
||||
{ key: 'service_type_label', label: 'Layanan', sortKey: 'service_type' },
|
||||
{ key: 'speed_label', label: 'Kecepatan', sortable: false },
|
||||
{ key: 'price_label', label: 'Harga', sortable: false },
|
||||
{ key: 'status_label', label: 'Status', sortKey: 'status' },
|
||||
],
|
||||
fields: [
|
||||
{ key: 'service_type', label: 'Tipe Layanan', type: 'select', options: [['ppp', 'PPP'], ['hotspot', 'Hotspot'], ['radius', 'RADIUS']] },
|
||||
{ key: 'external_profile_name', label: 'Nama Profile Eksternal' },
|
||||
{ key: 'download_kbps', label: 'Download (Kbps)', type: 'number' },
|
||||
{ key: 'upload_kbps', label: 'Upload (Kbps)', type: 'number' },
|
||||
{ key: 'local_address', label: 'Local Address' },
|
||||
{ key: 'remote_address_pool', label: 'Remote Address Pool' },
|
||||
{ key: 'session_timeout', label: 'Session Timeout (detik)', type: 'number' },
|
||||
{ key: 'idle_timeout', label: 'Idle Timeout (detik)', type: 'number' },
|
||||
{ key: 'shared_users', label: 'Shared Users', type: 'number' },
|
||||
{ key: 'price', label: 'Harga', type: 'number' },
|
||||
],
|
||||
},
|
||||
olt: {
|
||||
title: 'OLT',
|
||||
nameLabel: 'Nama OLT',
|
||||
columns: [
|
||||
{ key: 'name', label: 'Nama' },
|
||||
{ key: 'vendor', label: 'Vendor' },
|
||||
{ key: 'host', label: 'Host' },
|
||||
{ key: 'snmp_version', label: 'SNMP' },
|
||||
{ key: 'status_label', label: 'Status', sortKey: 'status' },
|
||||
],
|
||||
fields: [
|
||||
{ key: 'vendor', label: 'Vendor' },
|
||||
{ key: 'model', label: 'Model' },
|
||||
{ key: 'host', label: 'Host / IP', required: true },
|
||||
{ key: 'snmp_port', label: 'SNMP Port', type: 'number' },
|
||||
{ key: 'snmp_version', label: 'SNMP Version', type: 'select', options: [['v1', 'v1'], ['v2c', 'v2c'], ['v3', 'v3']] },
|
||||
{ key: 'community', label: 'Community', type: 'password', secret: true, show: (f) => f.snmp_version !== 'v3' },
|
||||
{ key: 'security_level', label: 'Security Level', type: 'select', options: [['noAuthNoPriv', 'noAuthNoPriv'], ['authNoPriv', 'authNoPriv'], ['authPriv', 'authPriv']], show: (f) => f.snmp_version === 'v3' },
|
||||
{ key: 'security_name', label: 'Security Name', show: (f) => f.snmp_version === 'v3' },
|
||||
{ key: 'auth_protocol', label: 'Auth Protocol', type: 'select', options: [['MD5', 'MD5'], ['SHA', 'SHA'], ['SHA256', 'SHA256'], ['SHA512', 'SHA512']], show: (f) => f.snmp_version === 'v3' && f.security_level !== 'noAuthNoPriv' },
|
||||
{ key: 'auth_password', label: 'Auth Password', type: 'password', secret: true, show: (f) => f.snmp_version === 'v3' && f.security_level !== 'noAuthNoPriv' },
|
||||
{ key: 'privacy_protocol', label: 'Privacy Protocol', type: 'select', options: [['DES', 'DES'], ['AES', 'AES'], ['AES256', 'AES256']], show: (f) => f.snmp_version === 'v3' && f.security_level === 'authPriv' },
|
||||
{ key: 'privacy_password', label: 'Privacy Password', type: 'password', secret: true, show: (f) => f.snmp_version === 'v3' && f.security_level === 'authPriv' },
|
||||
{ key: 'timeout', label: 'Timeout (detik)', type: 'number' },
|
||||
],
|
||||
},
|
||||
webfig: {
|
||||
title: 'Webfig',
|
||||
nameLabel: 'Nama Perangkat',
|
||||
columns: [
|
||||
{ key: 'name', label: 'Nama' },
|
||||
{ key: 'device_type', label: 'Tipe' },
|
||||
{ key: 'url', label: 'URL' },
|
||||
{ key: 'status_label', label: 'Status', sortKey: 'status' },
|
||||
],
|
||||
fields: [
|
||||
{ key: 'device_type', label: 'Tipe Perangkat' },
|
||||
{ key: 'url', label: 'URL Perangkat', type: 'url', required: true },
|
||||
{ key: 'username', label: 'Username' },
|
||||
{ key: 'password', label: 'Password', type: 'password', secret: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const config = computed(() => configs[resource.value])
|
||||
const loading = ref(true)
|
||||
const loadedOnce = ref(false)
|
||||
const saving = ref(false)
|
||||
const items = ref([])
|
||||
const tenants = ref([])
|
||||
const modal = ref(false)
|
||||
const detailModal = ref(false)
|
||||
const editingId = ref(null)
|
||||
const detailItem = ref(null)
|
||||
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
|
||||
const currentQuery = ref({ page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' })
|
||||
const form = reactive({})
|
||||
|
||||
const isMasterAdmin = computed(
|
||||
() => authStore.user?.is_master === true || authStore.user?.access_level === 'master_admin',
|
||||
)
|
||||
const actions = [
|
||||
{ key: 'detail', label: 'Detail', color: 'primary' },
|
||||
{ key: 'edit', label: 'Edit', color: 'success' },
|
||||
{ key: 'delete', label: 'Hapus', color: 'danger' },
|
||||
]
|
||||
|
||||
function defaults() {
|
||||
return {
|
||||
tenant_id: '',
|
||||
name: '',
|
||||
status: 'active',
|
||||
notes: '',
|
||||
connection_type: 'api',
|
||||
api_port: 8728,
|
||||
radius_auth_port: 1812,
|
||||
radius_accounting_port: 1813,
|
||||
timeout: resource.value === 'olt' ? 5 : 10,
|
||||
service_type: 'ppp',
|
||||
shared_users: 1,
|
||||
price: 0,
|
||||
snmp_port: 161,
|
||||
snmp_version: 'v2c',
|
||||
security_level: 'noAuthNoPriv',
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(item) {
|
||||
return {
|
||||
...item,
|
||||
connection_type_label: item.connection_type === 'radius' ? 'Radius Server' : 'Mikrotik API',
|
||||
service_type_label: ({ ppp: 'PPP', hotspot: 'Hotspot', radius: 'RADIUS' })[item.service_type] || '-',
|
||||
speed_label: item.download_kbps || item.upload_kbps ? `${item.download_kbps || 0}/${item.upload_kbps || 0} Kbps` : '-',
|
||||
price_label: Number(item.price || 0).toLocaleString('id-ID'),
|
||||
status_label: item.status === 'active' ? 'Aktif' : 'Nonaktif',
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
if (!isMasterAdmin.value || tenants.value.length) return
|
||||
const response = await getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' })
|
||||
tenants.value = response?.data?.data || []
|
||||
}
|
||||
|
||||
async function fetchData(params = currentQuery.value) {
|
||||
currentQuery.value = { ...currentQuery.value, ...params }
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getNasResources(resource.value, currentQuery.value)
|
||||
const rows = response?.data?.data || []
|
||||
items.value = rows.map(normalize)
|
||||
pagination.value = {
|
||||
current_page: response?.data?.current_page || 1,
|
||||
last_page: response?.data?.last_page || 1,
|
||||
per_page: response?.data?.per_page || 10,
|
||||
total: response?.data?.total || rows.length,
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || `Gagal memuat ${config.value.title}.`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadedOnce.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function openAdd(connectionType = null) {
|
||||
editingId.value = null
|
||||
Object.keys(form).forEach((key) => delete form[key])
|
||||
Object.assign(form, defaults())
|
||||
if (connectionType) form.connection_type = connectionType
|
||||
await loadTenants()
|
||||
modal.value = true
|
||||
}
|
||||
|
||||
async function openEdit(item) {
|
||||
editingId.value = item.id
|
||||
Object.keys(form).forEach((key) => delete form[key])
|
||||
Object.assign(form, defaults(), item)
|
||||
config.value.fields.filter((field) => field.secret).forEach((field) => {
|
||||
form[field.key] = ''
|
||||
})
|
||||
await loadTenants()
|
||||
modal.value = true
|
||||
}
|
||||
|
||||
async function openDetail(item) {
|
||||
try {
|
||||
const response = await getNasResource(resource.value, item.id)
|
||||
detailItem.value = normalize(response?.data || item)
|
||||
detailModal.value = true
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || 'Gagal memuat detail.')
|
||||
}
|
||||
}
|
||||
|
||||
function payload() {
|
||||
const data = { tenant_id: form.tenant_id || null, name: form.name?.trim(), status: form.status, notes: form.notes || null }
|
||||
if (resource.value === 'mikrotik') data.connection_type = form.connection_type
|
||||
config.value.fields.forEach((field) => {
|
||||
if (!field.show || field.show(form)) data[field.key] = form[field.key] === '' ? null : form[field.key]
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name?.trim()) return showError(`${config.value.nameLabel} wajib diisi.`)
|
||||
if (isMasterAdmin.value && !form.tenant_id) return showError('Tenant wajib dipilih.')
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) await updateNasResource(resource.value, editingId.value, payload())
|
||||
else await createNasResource(resource.value, payload())
|
||||
modal.value = false
|
||||
showSuccess(`${config.value.title} berhasil disimpan.`)
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || `Gagal menyimpan ${config.value.title}.`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item) {
|
||||
const result = await showConfirm('Konfirmasi Hapus', `Hapus "${item.name}"?`)
|
||||
if (!result.isConfirmed) return
|
||||
try {
|
||||
await deleteNasResource(resource.value, item.id)
|
||||
showSuccess(`${config.value.title} berhasil dihapus.`)
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || 'Gagal menghapus data.')
|
||||
}
|
||||
}
|
||||
|
||||
function onActionClick({ action, item }) {
|
||||
if (action === 'detail') openDetail(item)
|
||||
if (action === 'edit') openEdit(item)
|
||||
if (action === 'delete') remove(item)
|
||||
}
|
||||
|
||||
watch(resource, () => {
|
||||
loadedOnce.value = false
|
||||
items.value = []
|
||||
currentQuery.value = { page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' }
|
||||
fetchData()
|
||||
})
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CRow class="brdvx-page-row g-0 m-0">
|
||||
<CCol :xs="12" class="brdvx-page-column p-0">
|
||||
<div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<h5 class="mb-0 fw-semibold">{{ config.title }}</h5>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<template v-if="resource === 'mikrotik'">
|
||||
<CButton color="primary" size="sm" @click="openAdd('api')">+ Mikrotik API</CButton>
|
||||
<CButton color="info" size="sm" @click="openAdd('radius')">+ Radius Server</CButton>
|
||||
</template>
|
||||
<CButton v-else color="primary" size="sm" @click="openAdd()">+ Tambah {{ config.title }}</CButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading && !loadedOnce" class="brdvx-loading-state">
|
||||
<CSpinner color="primary" />
|
||||
<p class="brdvx-loading-text">Memuat data...</p>
|
||||
</div>
|
||||
<BaseResponsiveDataView
|
||||
v-else
|
||||
:columns="config.columns"
|
||||
:items="items"
|
||||
server-side
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
:actions="actions"
|
||||
row-key="id"
|
||||
@action-click="onActionClick"
|
||||
@query-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</CCol>
|
||||
</CRow>
|
||||
|
||||
<CModal :visible="modal" @close="modal = false" alignment="center" size="lg">
|
||||
<CModalHeader dismiss @close="modal = false">
|
||||
<CModalTitle>{{ editingId ? 'Edit' : 'Tambah' }} {{ config.title }}</CModalTitle>
|
||||
</CModalHeader>
|
||||
<CModalBody>
|
||||
<CRow class="g-3">
|
||||
<CCol v-if="isMasterAdmin" :md="6">
|
||||
<CFormLabel>Tenant</CFormLabel>
|
||||
<CFormSelect v-model="form.tenant_id">
|
||||
<option value="">Pilih tenant</option>
|
||||
<option v-for="tenant in tenants" :key="tenant.id" :value="tenant.id">{{ tenant.tenant_name }}</option>
|
||||
</CFormSelect>
|
||||
</CCol>
|
||||
<CCol :md="6">
|
||||
<CFormLabel>{{ config.nameLabel }}</CFormLabel>
|
||||
<CFormInput v-model="form.name" />
|
||||
</CCol>
|
||||
<CCol v-if="resource === 'mikrotik'" :md="6">
|
||||
<CFormLabel>Tipe Koneksi</CFormLabel>
|
||||
<CFormSelect v-model="form.connection_type">
|
||||
<option value="api">Mikrotik API</option>
|
||||
<option value="radius">Radius Server</option>
|
||||
</CFormSelect>
|
||||
</CCol>
|
||||
<CCol v-for="field in config.fields.filter((item) => !item.show || item.show(form))" :key="field.key" :md="6">
|
||||
<CFormLabel>{{ field.label }}</CFormLabel>
|
||||
<CFormSelect v-if="field.type === 'select'" v-model="form[field.key]">
|
||||
<option v-for="[value, label] in field.options" :key="value" :value="value">{{ label }}</option>
|
||||
</CFormSelect>
|
||||
<CFormInput
|
||||
v-else
|
||||
v-model="form[field.key]"
|
||||
:type="field.type || 'text'"
|
||||
:placeholder="field.secret && editingId ? 'Kosongkan jika tidak diubah' : ''"
|
||||
/>
|
||||
</CCol>
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Status</CFormLabel>
|
||||
<CFormSelect v-model="form.status">
|
||||
<option value="active">Aktif</option>
|
||||
<option value="inactive">Nonaktif</option>
|
||||
</CFormSelect>
|
||||
</CCol>
|
||||
<CCol :xs="12">
|
||||
<CFormLabel>Catatan</CFormLabel>
|
||||
<CFormTextarea v-model="form.notes" rows="2" />
|
||||
</CCol>
|
||||
</CRow>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="modal = false" :disabled="saving">Batal</CButton>
|
||||
<CButton color="primary" @click="save" :disabled="saving">{{ saving ? 'Menyimpan...' : 'Simpan' }}</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
|
||||
<CModal :visible="detailModal" @close="detailModal = false" alignment="center">
|
||||
<CModalHeader dismiss @close="detailModal = false"><CModalTitle>Detail {{ config.title }}</CModalTitle></CModalHeader>
|
||||
<CModalBody>
|
||||
<div v-if="detailItem" class="d-grid gap-2">
|
||||
<div v-for="column in config.columns" :key="column.key" class="d-flex justify-content-between gap-3 border-bottom pb-2">
|
||||
<span class="text-body-secondary">{{ column.label }}</span><strong class="text-end">{{ detailItem[column.key] || '-' }}</strong>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between gap-3"><span class="text-body-secondary">Tenant</span><strong>{{ detailItem.tenant?.tenant_name || '-' }}</strong></div>
|
||||
</div>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton v-if="resource === 'webfig' && detailItem?.url" color="primary" :href="detailItem.url" target="_blank" rel="noopener noreferrer">Buka Perangkat</CButton>
|
||||
<CButton color="secondary" @click="detailModal = false">Tutup</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
</template>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getTenants } from '@/services/tenantService'
|
||||
import { getCustomerOptions } from '@/services/customerService'
|
||||
import { getTopologyOptions } from '@/services/topologyService'
|
||||
import {
|
||||
approveBroadcast, createBroadcast, getBroadcasts, getNotificationChannels, getNotificationTemplates, previewBroadcast,
|
||||
} from '@/services/notificationService'
|
||||
import { showError, showSuccess } from '@/utils/swal.js'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isMaster = computed(() => authStore.user?.is_master || authStore.user?.access_level === 'master_admin')
|
||||
const loading = ref(true), loadedOnce = ref(false), saving = ref(false), formVisible = ref(false)
|
||||
const items = ref([]), tenants = ref([]), channels = ref([]), templates = ref([])
|
||||
const topologyNodes = ref([]), packageProfiles = ref([]), billingProfiles = ref([])
|
||||
const preview = ref(null)
|
||||
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
|
||||
const query = ref({ page: 1, per_page: 10 })
|
||||
const form = reactive({})
|
||||
const channelOptions = [
|
||||
['system', 'Aplikasi'], ['whatsapp_official', 'WhatsApp Official'],
|
||||
['whatsapp_unofficial', 'WhatsApp Unofficial'], ['email', 'Email'], ['telegram', 'Telegram'],
|
||||
]
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nama Siaran' }, { key: 'channel_label', label: 'Channel', sortable: false },
|
||||
{ key: 'recipient_label', label: 'Penerima', sortable: false }, { key: 'scheduled_label', label: 'Jadwal', sortable: false },
|
||||
{ key: 'status_label', label: 'Status' },
|
||||
]
|
||||
const actions = [
|
||||
{ key: 'approve', label: 'Setujui', color: 'success' },
|
||||
]
|
||||
|
||||
function defaults() {
|
||||
return {
|
||||
tenant_id: '', name: '', channel: 'whatsapp_official', notification_channel_id: '',
|
||||
notification_template_id: '', subject: '', body: '', scheduled_at: '',
|
||||
filters: { customer_status: ['active'], source: '', package_profile_id: '', billing_profile_id: '', topology_node_id: '' },
|
||||
}
|
||||
}
|
||||
function normalize(item) {
|
||||
const labels = Object.fromEntries(channelOptions)
|
||||
return {
|
||||
...item, channel_label: labels[item.channel] || item.channel,
|
||||
recipient_label: `${item.processed_recipients || 0} / ${item.estimated_recipients || 0}`,
|
||||
scheduled_label: item.scheduled_at || 'Segera',
|
||||
status_label: ({ draft: 'Draft', pending_approval: 'Menunggu Persetujuan', scheduled: 'Terjadwal', queued: 'Antrean', processing: 'Diproses', completed: 'Selesai', cancelled: 'Dibatalkan' })[item.status] || item.status,
|
||||
}
|
||||
}
|
||||
async function fetchData(params = query.value) {
|
||||
query.value = { ...query.value, ...params }; loading.value = true
|
||||
try {
|
||||
const response = await getBroadcasts(query.value); const rows = response?.data?.data || []
|
||||
items.value = rows.map(normalize)
|
||||
pagination.value = { current_page: response?.data?.current_page || 1, last_page: response?.data?.last_page || 1, per_page: response?.data?.per_page || 10, total: response?.data?.total || rows.length }
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Gagal memuat pesan siaran.') }
|
||||
finally { loading.value = false; loadedOnce.value = true }
|
||||
}
|
||||
async function loadOptions() {
|
||||
const calls = [getCustomerOptions(), getTopologyOptions()]
|
||||
if (isMaster.value) calls.push(getTenants({ per_page: 100, sort_by: 'tenant_name', sort_direction: 'asc' }))
|
||||
const [customerResponse, topologyResponse, tenantResponse] = await Promise.all(calls)
|
||||
packageProfiles.value = customerResponse?.data?.package_profiles || []
|
||||
billingProfiles.value = customerResponse?.data?.billing_profiles || []
|
||||
topologyNodes.value = topologyResponse?.data?.nodes || []
|
||||
tenants.value = tenantResponse?.data?.data || []
|
||||
}
|
||||
async function loadChannelOptions() {
|
||||
const [channelResponse, templateResponse] = await Promise.all([
|
||||
getNotificationChannels(form.channel), getNotificationTemplates(form.channel),
|
||||
])
|
||||
channels.value = (channelResponse?.data || []).filter((item) => item.enabled && (!form.tenant_id || Number(item.tenant_id) === Number(form.tenant_id)))
|
||||
templates.value = (templateResponse?.data || []).filter((item) => item.enabled && (!item.tenant_id || !form.tenant_id || Number(item.tenant_id) === Number(form.tenant_id)))
|
||||
form.notification_channel_id = channels.value.find((item) => item.is_default)?.id || channels.value[0]?.id || ''
|
||||
form.notification_template_id = ''
|
||||
}
|
||||
async function openForm() {
|
||||
Object.keys(form).forEach((key) => delete form[key]); Object.assign(form, defaults())
|
||||
preview.value = null; await loadOptions(); await loadChannelOptions(); formVisible.value = true
|
||||
}
|
||||
function payload() {
|
||||
return {
|
||||
...form, tenant_id: form.tenant_id || null,
|
||||
notification_channel_id: form.notification_channel_id || null,
|
||||
notification_template_id: form.notification_template_id || null,
|
||||
scheduled_at: form.scheduled_at || null,
|
||||
filters: Object.fromEntries(Object.entries(form.filters).map(([key, value]) => [key, value || undefined])),
|
||||
}
|
||||
}
|
||||
async function calculatePreview() {
|
||||
if (isMaster.value && !form.tenant_id) return showError('Tenant wajib dipilih.')
|
||||
saving.value = true
|
||||
try { preview.value = (await previewBroadcast(payload()))?.data }
|
||||
catch (error) { showError(error?.response?.data?.message || 'Gagal menghitung penerima.') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function submit() {
|
||||
if (!form.name?.trim()) return showError('Nama siaran wajib diisi.')
|
||||
if (form.channel === 'whatsapp_official' && !form.notification_template_id) return showError('WhatsApp Official wajib menggunakan template yang sudah disetujui.')
|
||||
if (!preview.value) return showError('Hitung dan periksa penerima terlebih dahulu.')
|
||||
saving.value = true
|
||||
try {
|
||||
await createBroadcast(payload()); formVisible.value = false
|
||||
showSuccess('Pesan siaran berhasil dimasukkan ke antrean.'); await fetchData()
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Gagal membuat pesan siaran.') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function onAction({ action, item }) {
|
||||
if (action !== 'approve' || item.status !== 'pending_approval') return
|
||||
try { await approveBroadcast(item.id); showSuccess('Siaran berhasil disetujui.'); await fetchData() }
|
||||
catch (error) { showError(error?.response?.data?.message || 'Siaran tidak dapat disetujui.') }
|
||||
}
|
||||
watch(() => form.channel, () => { if (formVisible.value) loadChannelOptions() })
|
||||
watch(() => form.tenant_id, () => { preview.value = null; if (formVisible.value) loadChannelOptions() })
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CRow class="brdvx-page-row g-0 m-0"><CCol :xs="12" class="brdvx-page-column p-0"><div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div><h5 class="mb-0 fw-semibold">Pesan Siaran</h5><small class="text-body-secondary">Broadcast tersegmentasi dengan snapshot penerima dan pengiriman melalui queue</small></div>
|
||||
<CButton color="primary" size="sm" @click="openForm">+ Buat Siaran</CButton>
|
||||
</div>
|
||||
<div v-if="loading && !loadedOnce" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div>
|
||||
<BaseResponsiveDataView v-else :columns="columns" :items="items" server-side :pagination="pagination" :loading="loading" :actions="actions" @query-change="fetchData" @action-click="onAction" />
|
||||
</div></CCol></CRow>
|
||||
|
||||
<CModal :visible="formVisible" @close="formVisible = false" size="xl"><CModalHeader dismiss @close="formVisible = false"><CModalTitle>Buat Pesan Siaran</CModalTitle></CModalHeader><CModalBody>
|
||||
<CRow class="g-3">
|
||||
<CCol v-if="isMaster" :md="6"><CFormLabel>Tenant</CFormLabel><CFormSelect v-model="form.tenant_id"><option value="">Pilih tenant</option><option v-for="tenant in tenants" :key="tenant.id" :value="tenant.id">{{ tenant.tenant_name }}</option></CFormSelect></CCol>
|
||||
<CCol :md="6"><CFormLabel>Nama Siaran</CFormLabel><CFormInput v-model="form.name" /></CCol>
|
||||
<CCol :md="4"><CFormLabel>Channel</CFormLabel><CFormSelect v-model="form.channel"><option v-for="[value, label] in channelOptions" :key="value" :value="value">{{ label }}</option></CFormSelect></CCol>
|
||||
<CCol :md="4"><CFormLabel>Konfigurasi Pengirim</CFormLabel><CFormSelect v-model="form.notification_channel_id" :disabled="form.channel === 'system'"><option value="">Default</option><option v-for="item in channels" :key="item.id" :value="item.id">{{ item.name }}</option></CFormSelect></CCol>
|
||||
<CCol :md="4"><CFormLabel>Template Pesan</CFormLabel><CFormSelect v-model="form.notification_template_id"><option value="">Pesan manual</option><option v-for="item in templates" :key="item.id" :value="item.id">{{ item.name }}</option></CFormSelect></CCol>
|
||||
<CCol v-if="form.channel === 'email' || form.channel === 'system'" :xs="12"><CFormLabel>Judul</CFormLabel><CFormInput v-model="form.subject" /></CCol>
|
||||
<CCol :xs="12"><CFormLabel>Pesan Manual</CFormLabel><CFormTextarea v-model="form.body" rows="5" :disabled="Boolean(form.notification_template_id)" /><CFormText>Variabel customer tetap dapat digunakan, misalnya {{customer.name}}.</CFormText></CCol>
|
||||
<CCol :xs="12"><h6 class="section-title">Filter Penerima</h6></CCol>
|
||||
<CCol :md="4"><CFormLabel>Status Customer</CFormLabel><CFormSelect v-model="form.filters.customer_status" multiple><option value="order">Order</option><option value="active">Aktif</option><option value="inactive">Tidak Aktif</option><option value="unmanaged">Unmanage</option></CFormSelect></CCol>
|
||||
<CCol :md="4"><CFormLabel>Status/Sumber Koneksi</CFormLabel><CFormSelect v-model="form.filters.source"><option value="">Semua</option><option value="manual">Terkelola</option><option value="nas">Terdeteksi NAS</option></CFormSelect></CCol>
|
||||
<CCol :md="4"><CFormLabel>Topologi</CFormLabel><CFormSelect v-model="form.filters.topology_node_id"><option value="">Semua topologi</option><option v-for="node in topologyNodes.filter((x) => !form.tenant_id || Number(x.tenant_id) === Number(form.tenant_id))" :key="node.id" :value="node.id">{{ node.device_type?.code }} · {{ node.code }} — {{ node.name }}</option></CFormSelect></CCol>
|
||||
<CCol :md="4"><CFormLabel>Profile Paket</CFormLabel><CFormSelect v-model="form.filters.package_profile_id"><option value="">Semua paket</option><option v-for="item in packageProfiles.filter((x) => !form.tenant_id || Number(x.tenant_id) === Number(form.tenant_id))" :key="item.id" :value="item.id">{{ item.name }}</option></CFormSelect></CCol>
|
||||
<CCol :md="4"><CFormLabel>Profile Tagihan</CFormLabel><CFormSelect v-model="form.filters.billing_profile_id"><option value="">Semua profile</option><option v-for="item in billingProfiles.filter((x) => !form.tenant_id || Number(x.tenant_id) === Number(form.tenant_id))" :key="item.id" :value="item.id">{{ item.name }}</option></CFormSelect></CCol>
|
||||
<CCol :md="4"><CFormLabel>Jadwalkan</CFormLabel><CFormInput v-model="form.scheduled_at" type="datetime-local" /></CCol>
|
||||
<CCol :xs="12"><CButton color="info" variant="outline" :disabled="saving" @click="calculatePreview">Hitung Penerima</CButton></CCol>
|
||||
<CCol v-if="preview" :xs="12"><CAlert color="info"><strong>{{ preview.estimated_recipients }}</strong> customer akan menerima pesan.<div class="sample-list"><span v-for="customer in preview.sample" :key="customer.id">{{ customer.customer_code }} · {{ customer.name }}</span></div></CAlert></CCol>
|
||||
</CRow>
|
||||
</CModalBody><CModalFooter><CButton color="secondary" @click="formVisible = false">Batal</CButton><CButton color="primary" :disabled="saving || !preview" @click="submit">Kirim/Jadwalkan</CButton></CModalFooter></CModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-title { margin: .5rem 0 0; padding-bottom: .5rem; border-bottom: 1px solid var(--cui-border-color); }
|
||||
.sample-list { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .6rem; }
|
||||
.sample-list span { padding: .25rem .45rem; font-size: .75rem; border: 1px solid currentColor; border-radius: 999px; }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
+59
-16
@@ -1,3 +1,41 @@
|
||||
<script setup>
|
||||
import { reactive, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = reactive({
|
||||
login: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const isLoading = computed(() => authStore.loading)
|
||||
const errorMessage = computed(() => authStore.error)
|
||||
|
||||
async function onSubmit() {
|
||||
authStore.clearError()
|
||||
|
||||
try {
|
||||
await authStore.login({
|
||||
login: form.login,
|
||||
password: form.password,
|
||||
})
|
||||
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
|
||||
await router.replace(redirect)
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function goToRegister() {
|
||||
router.push('/pages/register')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrapper min-vh-100 d-flex flex-row align-items-center">
|
||||
<CContainer>
|
||||
@@ -6,16 +44,24 @@
|
||||
<CCardGroup>
|
||||
<CCard class="p-4">
|
||||
<CCardBody>
|
||||
<CForm>
|
||||
<h1>Login</h1>
|
||||
<p class="text-body-secondary">Sign In to your account</p>
|
||||
<CForm @submit.prevent="onSubmit">
|
||||
<h1>Masuk</h1>
|
||||
<p class="text-body-secondary">Masuk ke akun anda</p>
|
||||
|
||||
<CAlert v-if="errorMessage" color="danger" class="mb-3">
|
||||
{{ errorMessage }}
|
||||
</CAlert>
|
||||
|
||||
<CInputGroup class="mb-3">
|
||||
<CInputGroupText>
|
||||
<CIcon icon="cil-user" />
|
||||
</CInputGroupText>
|
||||
<CFormInput
|
||||
placeholder="Username"
|
||||
v-model="form.login"
|
||||
type="text"
|
||||
placeholder="Username atau Email"
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
</CInputGroup>
|
||||
<CInputGroup class="mb-4">
|
||||
@@ -23,19 +69,20 @@
|
||||
<CIcon icon="cil-lock-locked" />
|
||||
</CInputGroupText>
|
||||
<CFormInput
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</CInputGroup>
|
||||
<CRow>
|
||||
<CCol :xs="6">
|
||||
<CButton color="primary" class="px-4"> Login </CButton>
|
||||
<CButton color="primary" class="px-4" type="submit" :disabled="isLoading">
|
||||
{{ isLoading ? 'Loading...' : 'Masuk' }}
|
||||
</CButton>
|
||||
</CCol>
|
||||
<CCol :xs="6" class="text-right">
|
||||
<CButton color="link" class="px-0">
|
||||
Forgot password?
|
||||
</CButton>
|
||||
</CCol>
|
||||
</CRow>
|
||||
</CForm>
|
||||
@@ -44,14 +91,10 @@
|
||||
<CCard class="text-white bg-primary py-5" style="width: 44%">
|
||||
<CCardBody class="text-center">
|
||||
<div>
|
||||
<h2>Sign up</h2>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit,
|
||||
sed do eiusmod tempor incididunt ut labore et dolore magna
|
||||
aliqua.
|
||||
</p>
|
||||
<CButton color="light" variant="outline" class="mt-3">
|
||||
Register Now!
|
||||
<h2>Belum punya akun?</h2>
|
||||
<p>Silakan registrasi untuk membuat akun baru.</p>
|
||||
<CButton color="light" variant="outline" class="mt-3" @click="goToRegister">
|
||||
Registrasi Sekarang!
|
||||
</CButton>
|
||||
</div>
|
||||
</CCardBody>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="permission-page">
|
||||
<div class="permission-card">
|
||||
<span class="permission-icon"><CIcon icon="cil-shield-alt" size="3xl" /></span>
|
||||
<div>
|
||||
<p class="permission-code">403</p>
|
||||
<h1>Tidak Memiliki Akses</h1>
|
||||
<p>Menu atau halaman ini tidak tersedia untuk akun yang sedang digunakan.</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-content-center gap-2">
|
||||
<CButton color="secondary" variant="outline" @click="router.back()">Kembali</CButton>
|
||||
<CButton color="primary" @click="router.push('/dashboard')">Ke Dashboard</CButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.permission-page { display: grid; min-height: calc(100vh - 9rem); place-items: center; padding: 1rem; }
|
||||
.permission-card { width: min(100%, 32rem); padding: 2rem; text-align: center; background: var(--cui-body-bg); border: 1px solid var(--cui-border-color); border-radius: 1rem; box-shadow: 0 .75rem 2rem rgba(0, 0, 0, .08); }
|
||||
.permission-icon { display: inline-flex; width: 5rem; height: 5rem; align-items: center; justify-content: center; margin-bottom: 1rem; color: #fff; background: linear-gradient(135deg, #f97316, #ff8c00); border-radius: 50%; }
|
||||
.permission-code { margin: 0; color: #ff8c00; font-size: .8rem; font-weight: 700; letter-spacing: .18em; }
|
||||
.permission-card h1 { margin: .25rem 0 .65rem; font-size: clamp(1.4rem, 4vw, 2rem); }
|
||||
.permission-card > div > p:last-child { color: var(--cui-secondary-color); }
|
||||
</style>
|
||||
@@ -1,3 +1,56 @@
|
||||
<script setup>
|
||||
import { reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
username: '',
|
||||
email: '',
|
||||
whatsapp_number: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
verification_channel: 'email',
|
||||
})
|
||||
|
||||
const isLoading = computed(() => authStore.loading)
|
||||
const errorMessage = computed(() => authStore.error)
|
||||
|
||||
async function onSubmit() {
|
||||
authStore.clearError()
|
||||
|
||||
if (form.password !== form.password_confirmation) {
|
||||
authStore.error = 'Konfirmasi password tidak sama.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await authStore.register({
|
||||
name: form.name,
|
||||
username: form.username,
|
||||
email: form.email,
|
||||
whatsapp_number: form.whatsapp_number,
|
||||
password: form.password,
|
||||
password_confirmation: form.password_confirmation,
|
||||
verification_channel: form.verification_channel,
|
||||
verification_target:
|
||||
form.verification_channel === 'whatsapp' ? form.whatsapp_number : form.email,
|
||||
})
|
||||
|
||||
await router.push('/pages/verify-code')
|
||||
} catch (error) {
|
||||
console.error('Register failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
router.push('/pages/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bwrapper min-vh-100 d-flex flex-row align-items-center">
|
||||
<CContainer>
|
||||
@@ -5,27 +58,69 @@
|
||||
<CCol :md="9" :lg="7" :xl="6">
|
||||
<CCard class="mx-4">
|
||||
<CCardBody class="p-4">
|
||||
<CForm>
|
||||
<CForm @submit.prevent="onSubmit">
|
||||
<h1>Register</h1>
|
||||
<p class="text-body-secondary">Create your account</p>
|
||||
|
||||
<CAlert v-if="errorMessage" color="danger" class="mb-3">
|
||||
{{ errorMessage }}
|
||||
</CAlert>
|
||||
|
||||
<CInputGroup class="mb-3">
|
||||
<CInputGroupText>
|
||||
<CIcon icon="cil-user" />
|
||||
</CInputGroupText>
|
||||
<CFormInput placeholder="Username" autocomplete="username" />
|
||||
<CFormInput
|
||||
v-model="form.name"
|
||||
placeholder="Nama Lengkap"
|
||||
autocomplete="name"
|
||||
required
|
||||
/>
|
||||
</CInputGroup>
|
||||
<CInputGroup class="mb-3">
|
||||
<CInputGroupText>
|
||||
<CIcon icon="cil-user-follow" />
|
||||
</CInputGroupText>
|
||||
<CFormInput
|
||||
v-model="form.username"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
</CInputGroup>
|
||||
|
||||
<CInputGroup class="mb-3">
|
||||
<CInputGroupText>@</CInputGroupText>
|
||||
<CFormInput placeholder="Email" autocomplete="email" />
|
||||
<CFormInput
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
</CInputGroup>
|
||||
|
||||
<CInputGroup class="mb-3">
|
||||
<CInputGroupText>
|
||||
<CIcon icon="cil-phone" />
|
||||
</CInputGroupText>
|
||||
<CFormInput
|
||||
v-model="form.whatsapp_number"
|
||||
placeholder="WhatsApp Number"
|
||||
autocomplete="tel"
|
||||
required
|
||||
/>
|
||||
</CInputGroup>
|
||||
<CInputGroup class="mb-3">
|
||||
<CInputGroupText>
|
||||
<CIcon icon="cil-lock-locked" />
|
||||
</CInputGroupText>
|
||||
<CFormInput
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</CInputGroup>
|
||||
<CInputGroup class="mb-4">
|
||||
@@ -33,13 +128,20 @@
|
||||
<CIcon icon="cil-lock-locked" />
|
||||
</CInputGroupText>
|
||||
<CFormInput
|
||||
v-model="form.password_confirmation"
|
||||
type="password"
|
||||
placeholder="Repeat password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</CInputGroup>
|
||||
<div class="d-grid">
|
||||
<CButton color="success">Create Account</CButton>
|
||||
<div class="d-grid gap-2">
|
||||
<CButton color="success" type="submit" :disabled="isLoading">
|
||||
{{ isLoading ? 'Memproses...' : 'Create Account' }}
|
||||
</CButton>
|
||||
<CButton color="light" variant="outline" type="button" @click="goToLogin">
|
||||
Kembali ke Login
|
||||
</CButton>
|
||||
</div>
|
||||
</CForm>
|
||||
</CCardBody>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup>
|
||||
import { reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const form = reactive({
|
||||
verification_channel: authStore.verificationChannel || 'email',
|
||||
verification_target: authStore.verificationTarget || authStore.pendingVerificationEmail || '',
|
||||
verification_code: '',
|
||||
})
|
||||
|
||||
const isLoading = computed(() => authStore.loading)
|
||||
const errorMessage = computed(() => authStore.error)
|
||||
const temporaryVerificationCode = computed(() => authStore.verificationCode)
|
||||
|
||||
function useTemporaryCode() {
|
||||
form.verification_code = temporaryVerificationCode.value
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
authStore.clearError()
|
||||
|
||||
try {
|
||||
await authStore.verifyCode({
|
||||
verification_channel: form.verification_channel,
|
||||
verification_target: form.verification_target,
|
||||
verification_code: form.verification_code,
|
||||
})
|
||||
|
||||
await router.push('/dashboard')
|
||||
} catch (error) {
|
||||
// error handled in store
|
||||
console.error('Verify code failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function onResend() {
|
||||
authStore.clearError()
|
||||
|
||||
try {
|
||||
await authStore.resendVerificationCode({
|
||||
verification_channel: form.verification_channel,
|
||||
verification_target: form.verification_target,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Resend verification failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function goToLogin() {
|
||||
router.push('/pages/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrapper min-vh-100 d-flex flex-row align-items-center">
|
||||
<CContainer>
|
||||
<CRow class="justify-content-center">
|
||||
<CCol :md="7" :lg="6">
|
||||
<CCard class="mx-4">
|
||||
<CCardBody class="p-4">
|
||||
<CForm @submit.prevent="onSubmit">
|
||||
<h1>Verifikasi Kode</h1>
|
||||
<p class="text-body-secondary">Masukkan target verifikasi dan kode OTP</p>
|
||||
|
||||
<CAlert v-if="errorMessage" color="danger" class="mb-3">
|
||||
{{ errorMessage }}
|
||||
</CAlert>
|
||||
|
||||
<CInputGroup class="mb-3">
|
||||
<CInputGroupText>
|
||||
<CIcon icon="cil-envelope-open" />
|
||||
</CInputGroupText>
|
||||
<CFormSelect v-model="form.verification_channel" required>
|
||||
<option value="email">Email</option>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
</CFormSelect>
|
||||
</CInputGroup>
|
||||
|
||||
<CInputGroup class="mb-3">
|
||||
<CInputGroupText>
|
||||
<CIcon icon="cil-at" />
|
||||
</CInputGroupText>
|
||||
<CFormInput
|
||||
v-model="form.verification_target"
|
||||
placeholder="Verification Target (email/whatsapp number)"
|
||||
required
|
||||
/>
|
||||
</CInputGroup>
|
||||
|
||||
<CInputGroup class="mb-4">
|
||||
<CInputGroupText>
|
||||
<CIcon icon="cil-shield-alt" />
|
||||
</CInputGroupText>
|
||||
<CFormInput
|
||||
v-model="form.verification_code"
|
||||
placeholder="Kode Verifikasi"
|
||||
autocomplete="one-time-code"
|
||||
required
|
||||
/>
|
||||
</CInputGroup>
|
||||
|
||||
<CAlert
|
||||
v-if="temporaryVerificationCode"
|
||||
color="warning"
|
||||
class="temporary-code-box mb-4"
|
||||
>
|
||||
<div>
|
||||
<small class="d-block text-body-secondary">Kode verifikasi sementara</small>
|
||||
<strong class="temporary-code-value">{{ temporaryVerificationCode }}</strong>
|
||||
<small class="d-block mt-1">
|
||||
Kode ditampilkan di sini karena channel pengiriman belum aktif.
|
||||
</small>
|
||||
</div>
|
||||
<CButton color="warning" size="sm" type="button" @click="useTemporaryCode">
|
||||
Gunakan kode
|
||||
</CButton>
|
||||
</CAlert>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<CButton color="primary" type="submit" :disabled="isLoading">
|
||||
{{ isLoading ? 'Memverifikasi...' : 'Verifikasi' }}
|
||||
</CButton>
|
||||
|
||||
<CButton color="warning" variant="outline" type="button" @click="onResend">
|
||||
Kirim Ulang Kode
|
||||
</CButton>
|
||||
|
||||
<CButton color="light" variant="outline" type="button" @click="goToLogin">
|
||||
Kembali ke Login
|
||||
</CButton>
|
||||
</div>
|
||||
</CForm>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
</CRow>
|
||||
</CContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.temporary-code-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.temporary-code-value {
|
||||
font-size: 1.45rem;
|
||||
letter-spacing: 0.32rem;
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
.temporary-code-box {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { createGatewayAccount, getPaymentProviders, getPaymentSettings, saveTenantPaymentSetting, updateGatewayAccount } from '@/services/paymentGatewayService'
|
||||
import { showError, showSuccess } from '@/utils/swal'
|
||||
|
||||
const loading = ref(true), saving = ref(false), providers = ref([]), accounts = ref([]), setting = reactive({})
|
||||
const modal = ref(false), editing = ref(null), form = reactive({})
|
||||
const tenantAccounts = computed(() => accounts.value.filter((x) => x.scope === 'tenant'))
|
||||
const platformAccounts = computed(() => accounts.value.filter((x) => x.scope === 'platform' && x.enabled))
|
||||
const selectedProvider = computed(() => providers.value.find((x) => Number(x.id) === Number(form.payment_provider_id)))
|
||||
const defaults = () => ({ payment_provider_id: '', scope: 'tenant', name: '', environment: 'sandbox', credentials: {}, webhook_secret: '', settings: {}, enabled: false, is_default: false })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [providerResponse, settingResponse] = await Promise.all([getPaymentProviders(), getPaymentSettings()])
|
||||
providers.value = providerResponse?.data || []; accounts.value = settingResponse?.data?.accounts || []
|
||||
Object.assign(setting, { payment_mode: 'disabled', fee_bearer: 'customer', default_expiry_minutes: 1440, enabled_methods: [], ...(settingResponse?.data?.setting || {}) })
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Gagal memuat pengaturan pembayaran.') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function open(account = null) {
|
||||
editing.value = account; Object.keys(form).forEach((key) => delete form[key])
|
||||
Object.assign(form, defaults(), account || {}, { credentials: {}, webhook_secret: '' }); modal.value = true
|
||||
}
|
||||
async function saveAccount() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) await updateGatewayAccount(editing.value.id, form); else await createGatewayAccount(form)
|
||||
modal.value = false; showSuccess('Konfigurasi gateway berhasil disimpan.'); await load()
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Konfigurasi gateway gagal disimpan.') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function saveMode() {
|
||||
saving.value = true
|
||||
try { await saveTenantPaymentSetting(setting); showSuccess('Mode pembayaran berhasil disimpan.'); await load() }
|
||||
catch (error) { showError(error?.response?.data?.message || 'Mode pembayaran gagal disimpan.') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header d-flex justify-content-between align-items-center"><div><h5 class="mb-0 fw-semibold">Setting Payment Gateway</h5><small class="text-body-secondary">Pilih gateway tenant atau gateway global platform</small></div><CButton color="primary" size="sm" @click="open()">+ Gateway</CButton></div>
|
||||
<div v-if="loading" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div>
|
||||
<CRow v-else class="g-3">
|
||||
<CCol :lg="5"><CCard class="h-100"><CCardBody><h6>Mode Pembayaran Tenant</h6>
|
||||
<CFormLabel>Mode</CFormLabel><CFormSelect v-model="setting.payment_mode" class="mb-3"><option value="disabled">Nonaktif</option><option value="tenant_gateway">Gateway milik tenant</option><option value="platform_gateway">Gateway global platform</option></CFormSelect>
|
||||
<div v-if="setting.payment_mode === 'tenant_gateway'"><CFormLabel>Gateway Tenant Aktif</CFormLabel><CFormSelect v-model="setting.tenant_gateway_account_id" class="mb-3"><option value="">Pilih gateway</option><option v-for="item in tenantAccounts" :key="item.id" :value="item.id">{{ item.name }} · {{ item.provider?.name }}</option></CFormSelect></div>
|
||||
<div v-if="setting.payment_mode === 'platform_gateway'"><CFormLabel>Gateway Platform</CFormLabel><CFormSelect v-model="setting.platform_gateway_account_id" class="mb-3"><option value="">Pilih gateway</option><option v-for="item in platformAccounts" :key="item.id" :value="item.id">{{ item.name }} · {{ item.provider?.name }}</option></CFormSelect></div>
|
||||
<CFormLabel>Penanggung Biaya</CFormLabel><CFormSelect v-model="setting.fee_bearer" class="mb-3"><option value="customer">Customer</option><option value="tenant">Tenant</option><option value="platform">Platform</option></CFormSelect>
|
||||
<CFormLabel>Kedaluwarsa Pembayaran (menit)</CFormLabel><CFormInput v-model.number="setting.default_expiry_minutes" type="number" min="5" class="mb-3" />
|
||||
<CButton color="primary" :disabled="saving" @click="saveMode">Simpan Mode</CButton>
|
||||
</CCardBody></CCard></CCol>
|
||||
<CCol :lg="7"><CCard><CCardBody><h6>Daftar Akun Gateway</h6><div v-if="!accounts.length" class="text-body-secondary py-4 text-center">Belum ada akun gateway.</div>
|
||||
<button v-for="item in accounts" :key="item.id" type="button" class="gateway-row" @click="open(item)"><span><strong>{{ item.name }}</strong><small>{{ item.provider?.name }} · {{ item.scope === 'platform' ? 'Global Platform' : 'Tenant' }} · {{ item.environment }}</small></span><CBadge :color="item.enabled ? 'success' : 'secondary'">{{ item.enabled ? 'Aktif' : 'Nonaktif' }}</CBadge></button>
|
||||
</CCardBody></CCard></CCol>
|
||||
</CRow>
|
||||
</div>
|
||||
<CModal :visible="modal" @close="modal = false"><CModalHeader><CModalTitle>Konfigurasi Gateway</CModalTitle></CModalHeader><CModalBody>
|
||||
<CFormLabel>Provider</CFormLabel><CFormSelect v-model="form.payment_provider_id" class="mb-2"><option value="">Pilih provider</option><option v-for="item in providers" :key="item.id" :value="item.id">{{ item.name }}</option></CFormSelect>
|
||||
<CFormLabel>Lingkup</CFormLabel><CFormSelect v-model="form.scope" class="mb-2"><option value="tenant">Tenant</option><option value="platform">Global Platform</option></CFormSelect>
|
||||
<CFormLabel>Nama Akun</CFormLabel><CFormInput v-model="form.name" class="mb-2" />
|
||||
<CFormLabel>Environment</CFormLabel><CFormSelect v-model="form.environment" class="mb-2"><option value="sandbox">Sandbox</option><option value="production">Production</option></CFormSelect>
|
||||
<div v-for="field in selectedProvider?.credential_schema || []" :key="field.key"><CFormLabel>{{ field.label }}</CFormLabel><CFormInput v-model="form.credentials[field.key]" :type="field.secret ? 'password' : 'text'" :placeholder="editing?.has_credentials ? 'Kosongkan jika tidak diubah' : ''" class="mb-2" /></div>
|
||||
<CFormLabel>Webhook Secret</CFormLabel><CFormInput v-model="form.webhook_secret" type="password" class="mb-2" />
|
||||
<CFormCheck v-model="form.enabled" label="Aktif" /><CFormCheck v-if="form.scope === 'platform'" v-model="form.is_default" label="Default platform" />
|
||||
</CModalBody><CModalFooter><CButton color="secondary" @click="modal = false">Batal</CButton><CButton color="primary" :disabled="saving" @click="saveAccount">Simpan</CButton></CModalFooter></CModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gateway-row { display:flex; width:100%; align-items:center; justify-content:space-between; padding:.75rem 0; color:inherit; text-align:left; background:none; border:0; border-bottom:1px solid var(--cui-border-color); }
|
||||
.gateway-row span { display:flex; flex-direction:column; }.gateway-row small { color:var(--cui-secondary-color); }
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import { getPaymentTransactions } from '@/services/paymentGatewayService'
|
||||
import { showError } from '@/utils/swal'
|
||||
|
||||
const loading = ref(true), loaded = ref(false), items = ref([])
|
||||
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
|
||||
const query = ref({ page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' })
|
||||
const columns = [
|
||||
{ key: 'transaction_number', label: 'Nomor Transaksi' }, { key: 'customer_label', label: 'Customer/User' },
|
||||
{ key: 'provider_label', label: 'Provider' }, { key: 'purpose_label', label: 'Tujuan' },
|
||||
{ key: 'total_label', label: 'Total', sortKey: 'amount' }, { key: 'status', label: 'Status', sortKey: 'status' },
|
||||
{ key: 'created_label', label: 'Dibuat', sortKey: 'created_at' },
|
||||
]
|
||||
const filterFields = [
|
||||
{ key: 'date_from', label: 'Tanggal Mulai', type: 'date' }, { key: 'date_to', label: 'Tanggal Akhir', type: 'date' },
|
||||
{ key: 'status', label: 'Status', type: 'select', options: [['created', 'Dibuat'], ['pending', 'Menunggu'], ['paid', 'Lunas'], ['failed', 'Gagal'], ['expired', 'Kedaluwarsa'], ['refunded', 'Refund']] },
|
||||
{ key: 'purpose', label: 'Tujuan', type: 'select', options: [['invoice_payment', 'Bayar Tagihan'], ['wallet_topup', 'Isi Saldo'], ['deposit', 'Deposit']] },
|
||||
]
|
||||
const money = (value) => `Rp ${Number(value || 0).toLocaleString('id-ID')}`
|
||||
async function fetchData(params = query.value) {
|
||||
query.value = { ...query.value, ...params }; loading.value = true
|
||||
try {
|
||||
const response = await getPaymentTransactions(query.value), rows = response?.data?.data || []
|
||||
items.value = rows.map((x) => ({ ...x, customer_label: x.customer?.name || x.user?.name || '-', provider_label: x.provider?.name || x.provider_code, purpose_label: { invoice_payment: 'Bayar Tagihan', wallet_topup: 'Isi Saldo', deposit: 'Deposit' }[x.purpose] || x.purpose, total_label: money(x.total_amount), created_label: new Date(x.created_at).toLocaleString('id-ID') }))
|
||||
pagination.value = { current_page: response?.data?.current_page || 1, last_page: response?.data?.last_page || 1, per_page: response?.data?.per_page || 10, total: response?.data?.total || 0 }
|
||||
} catch (error) { showError(error?.response?.data?.message || 'Gagal memuat log pembayaran.') }
|
||||
finally { loading.value = false; loaded.value = true }
|
||||
}
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
<template><div class="brdvx-page-container"><div class="brdvx-page-header"><h5 class="mb-0 fw-semibold">Log Pembayaran</h5><small class="text-body-secondary">Histori pembayaran customer dan transaksi gateway</small></div><div v-if="loading && !loaded" class="brdvx-loading-state"><CSpinner color="primary" /><p class="brdvx-loading-text">Memuat data...</p></div><BaseResponsiveDataView v-else :columns="columns" :items="items" :filter-fields="filterFields" server-side :pagination="pagination" :loading="loading" @query-change="fetchData" /></div></template>
|
||||
@@ -0,0 +1,505 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getMyProfile, updateMyProfile } from '@/services/profileService'
|
||||
import { deleteFile, getTemporaryFileUrl, uploadFile } from '@/services/fileService'
|
||||
import {
|
||||
getDesaOptions,
|
||||
getKabupatenOptions,
|
||||
getKecamatanOptions,
|
||||
getProvinsiOptions,
|
||||
} from '@/services/wilayahService'
|
||||
import { showError, showSuccess } from '@/utils/swal'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const profile = ref(null)
|
||||
const selectedPhoto = ref(null)
|
||||
const photoUrl = ref('')
|
||||
const localPhotoUrl = ref('')
|
||||
const photoInput = ref(null)
|
||||
const photoDragging = ref(false)
|
||||
const wilayahOptions = reactive({ provinsi: [], kabupaten: [], kecamatan: [], desa: [] })
|
||||
const wilayahLoading = reactive({ provinsi: false, kabupaten: false, kecamatan: false, desa: false })
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
username: '',
|
||||
email: '',
|
||||
whatsapp_number: '',
|
||||
nik: '',
|
||||
address: '',
|
||||
provinsi_id: '',
|
||||
kabupaten_id: '',
|
||||
kecamatan_id: '',
|
||||
desa_id: '',
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
})
|
||||
|
||||
const accessLevelLabel = computed(() => {
|
||||
const labels = {
|
||||
customer: 'Customer',
|
||||
staff: 'Staff',
|
||||
tenant_owner: 'Pemilik Tenant',
|
||||
master_admin: 'Master Admin',
|
||||
}
|
||||
|
||||
return labels[profile.value?.access_level] || profile.value?.access_level || '-'
|
||||
})
|
||||
|
||||
const tenants = computed(() => profile.value?.user_tenants || [])
|
||||
const menuGroups = computed(() => profile.value?.user_menu_groups || [])
|
||||
const displayedPhotoUrl = computed(() => localPhotoUrl.value || photoUrl.value)
|
||||
|
||||
function fillForm(user) {
|
||||
const userProfile = user?.user_profile || {}
|
||||
|
||||
Object.assign(form, {
|
||||
name: user?.name || '',
|
||||
username: user?.username || '',
|
||||
email: user?.email || '',
|
||||
whatsapp_number: user?.whatsapp_number || '',
|
||||
nik: userProfile.nik || '',
|
||||
address: userProfile.address || '',
|
||||
provinsi_id: userProfile.provinsi_id ? String(userProfile.provinsi_id) : '',
|
||||
kabupaten_id: userProfile.kabupaten_id ? String(userProfile.kabupaten_id) : '',
|
||||
kecamatan_id: userProfile.kecamatan_id ? String(userProfile.kecamatan_id) : '',
|
||||
desa_id: userProfile.desa_id ? String(userProfile.desa_id) : '',
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
})
|
||||
}
|
||||
|
||||
function optionRows(response) {
|
||||
return Array.isArray(response?.data) ? response.data : []
|
||||
}
|
||||
|
||||
async function loadWilayahOptions(level, parentId = null) {
|
||||
wilayahLoading[level] = true
|
||||
try {
|
||||
const loaders = {
|
||||
provinsi: () => getProvinsiOptions(),
|
||||
kabupaten: () => getKabupatenOptions(parentId),
|
||||
kecamatan: () => getKecamatanOptions(parentId),
|
||||
desa: () => getDesaOptions(parentId),
|
||||
}
|
||||
const response = await loaders[level]()
|
||||
wilayahOptions[level] = optionRows(response)
|
||||
} catch (error) {
|
||||
wilayahOptions[level] = []
|
||||
showError(error?.response?.data?.message || `Gagal memuat pilihan ${level}.`)
|
||||
} finally {
|
||||
wilayahLoading[level] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateWilayahOptions() {
|
||||
await loadWilayahOptions('provinsi')
|
||||
if (form.provinsi_id) await loadWilayahOptions('kabupaten', form.provinsi_id)
|
||||
if (form.kabupaten_id) await loadWilayahOptions('kecamatan', form.kabupaten_id)
|
||||
if (form.kecamatan_id) await loadWilayahOptions('desa', form.kecamatan_id)
|
||||
}
|
||||
|
||||
async function onProvinsiChange(event) {
|
||||
form.provinsi_id = event.target.value
|
||||
form.kabupaten_id = ''
|
||||
form.kecamatan_id = ''
|
||||
form.desa_id = ''
|
||||
wilayahOptions.kabupaten = []
|
||||
wilayahOptions.kecamatan = []
|
||||
wilayahOptions.desa = []
|
||||
if (form.provinsi_id) await loadWilayahOptions('kabupaten', form.provinsi_id)
|
||||
}
|
||||
|
||||
async function onKabupatenChange(event) {
|
||||
form.kabupaten_id = event.target.value
|
||||
form.kecamatan_id = ''
|
||||
form.desa_id = ''
|
||||
wilayahOptions.kecamatan = []
|
||||
wilayahOptions.desa = []
|
||||
if (form.kabupaten_id) await loadWilayahOptions('kecamatan', form.kabupaten_id)
|
||||
}
|
||||
|
||||
async function onKecamatanChange(event) {
|
||||
form.kecamatan_id = event.target.value
|
||||
form.desa_id = ''
|
||||
wilayahOptions.desa = []
|
||||
if (form.kecamatan_id) await loadWilayahOptions('desa', form.kecamatan_id)
|
||||
}
|
||||
|
||||
async function fetchProfile() {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await getMyProfile()
|
||||
profile.value = response?.data || null
|
||||
fillForm(profile.value)
|
||||
await hydrateWilayahOptions()
|
||||
await loadProfilePhoto(profile.value)
|
||||
authStore.setUser(profile.value)
|
||||
} catch (error) {
|
||||
showError(error?.response?.data?.message || 'Gagal memuat profile.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProfilePhoto(user) {
|
||||
photoUrl.value = ''
|
||||
|
||||
if (!user?.profile_photo?.uuid) return
|
||||
|
||||
try {
|
||||
const response = await getTemporaryFileUrl(user.profile_photo.uuid)
|
||||
photoUrl.value = response?.data?.url || ''
|
||||
} catch {
|
||||
photoUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectedPhoto(file, input = null) {
|
||||
|
||||
if (file && !file.type.startsWith('image/')) {
|
||||
if (input) input.value = ''
|
||||
showError('Foto profil harus berupa file gambar.')
|
||||
return
|
||||
}
|
||||
|
||||
if (file && file.size > 5 * 1024 * 1024) {
|
||||
if (input) input.value = ''
|
||||
showError('Ukuran foto profil maksimal 5 MB.')
|
||||
return
|
||||
}
|
||||
|
||||
if (localPhotoUrl.value) URL.revokeObjectURL(localPhotoUrl.value)
|
||||
selectedPhoto.value = file
|
||||
localPhotoUrl.value = file ? URL.createObjectURL(file) : ''
|
||||
}
|
||||
|
||||
function selectPhoto(event) {
|
||||
setSelectedPhoto(event.target.files?.[0] || null, event.target)
|
||||
}
|
||||
|
||||
function dropPhoto(event) {
|
||||
photoDragging.value = false
|
||||
setSelectedPhoto(event.dataTransfer?.files?.[0] || null)
|
||||
}
|
||||
|
||||
function clearSelectedPhoto() {
|
||||
if (localPhotoUrl.value) URL.revokeObjectURL(localPhotoUrl.value)
|
||||
selectedPhoto.value = null
|
||||
localPhotoUrl.value = ''
|
||||
if (photoInput.value) photoInput.value.value = ''
|
||||
}
|
||||
|
||||
function nullableNumber(value) {
|
||||
return value === '' || value === null ? null : Number(value)
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
username: form.username.trim() || null,
|
||||
email: form.email.trim(),
|
||||
whatsapp_number: form.whatsapp_number.trim() || null,
|
||||
user_profile: {
|
||||
nik: form.nik.trim() || null,
|
||||
address: form.address.trim() || null,
|
||||
provinsi_id: nullableNumber(form.provinsi_id),
|
||||
kabupaten_id: nullableNumber(form.kabupaten_id),
|
||||
kecamatan_id: nullableNumber(form.kecamatan_id),
|
||||
desa_id: nullableNumber(form.desa_id),
|
||||
},
|
||||
}
|
||||
|
||||
if (form.password) {
|
||||
payload.current_password = form.current_password
|
||||
payload.password = form.password
|
||||
payload.password_confirmation = form.password_confirmation
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
let uploadedPhoto = null
|
||||
|
||||
if (selectedPhoto.value) {
|
||||
const uploadResponse = await uploadFile(selectedPhoto.value, 'profile')
|
||||
uploadedPhoto = uploadResponse?.data || null
|
||||
payload.profile_photo_file_id = uploadedPhoto?.id
|
||||
}
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await updateMyProfile(payload)
|
||||
} catch (error) {
|
||||
if (uploadedPhoto?.uuid) {
|
||||
await deleteFile(uploadedPhoto.uuid).catch(() => {})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
profile.value = response?.data || null
|
||||
fillForm(profile.value)
|
||||
selectedPhoto.value = null
|
||||
if (localPhotoUrl.value) URL.revokeObjectURL(localPhotoUrl.value)
|
||||
localPhotoUrl.value = ''
|
||||
await loadProfilePhoto(profile.value)
|
||||
authStore.setUser(profile.value)
|
||||
showSuccess('Profile berhasil diperbarui.')
|
||||
} catch (error) {
|
||||
const errors = error?.response?.data?.errors
|
||||
const firstError = errors ? Object.values(errors).flat()[0] : null
|
||||
showError(firstError || error?.response?.data?.message || 'Gagal memperbarui profile.')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchProfile)
|
||||
onBeforeUnmount(() => {
|
||||
if (localPhotoUrl.value) URL.revokeObjectURL(localPhotoUrl.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CRow class="brdvx-page-row g-0 m-0">
|
||||
<CCol :xs="12" class="brdvx-page-column p-0">
|
||||
<div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header">
|
||||
<h5 class="mb-1 fw-semibold">Profile Saya</h5>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-5">
|
||||
<CSpinner color="primary" />
|
||||
</div>
|
||||
|
||||
<CRow v-else-if="profile" class="g-3">
|
||||
<CCol :lg="4">
|
||||
<CCard class="h-100">
|
||||
<CCardBody class="text-center">
|
||||
<CAvatar
|
||||
:src="displayedPhotoUrl || undefined"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
class="image-previewable profile-photo-avatar"
|
||||
:data-preview-alt="`Foto ${profile.name}`"
|
||||
>
|
||||
<template v-if="!displayedPhotoUrl">
|
||||
{{ profile.name?.charAt(0)?.toUpperCase() || 'U' }}
|
||||
</template>
|
||||
</CAvatar>
|
||||
<div
|
||||
class="profile-photo-uploader"
|
||||
:class="{ 'is-dragging': photoDragging }"
|
||||
@dragenter.prevent="photoDragging = true"
|
||||
@dragover.prevent="photoDragging = true"
|
||||
@dragleave.prevent="photoDragging = false"
|
||||
@drop.prevent="dropPhoto"
|
||||
>
|
||||
<div class="profile-photo-uploader-icon">
|
||||
<CIcon icon="cil-cloud-download" size="xl" />
|
||||
</div>
|
||||
<div class="fw-semibold">Ganti foto profil</div>
|
||||
<div class="small text-body-secondary">
|
||||
Tarik gambar ke sini atau pilih dari perangkat
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-content-center gap-2 mt-3">
|
||||
<CButton color="primary" variant="outline" size="sm" @click="photoInput?.click()">
|
||||
<CIcon icon="cil-camera" class="me-1" />
|
||||
Pilih Gambar
|
||||
</CButton>
|
||||
<CButton
|
||||
v-if="selectedPhoto"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="clearSelectedPhoto"
|
||||
>
|
||||
Batal
|
||||
</CButton>
|
||||
</div>
|
||||
<div v-if="selectedPhoto" class="profile-photo-file mt-3">
|
||||
<CIcon icon="cil-check-circle" class="text-success" />
|
||||
<span>{{ selectedPhoto.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="photoInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
class="d-none"
|
||||
aria-label="Pilih foto profile"
|
||||
@change="selectPhoto"
|
||||
/>
|
||||
<div class="small text-body-secondary mt-2 mb-3">
|
||||
JPG, PNG, WebP, atau GIF. Maksimal 5 MB.
|
||||
</div>
|
||||
<h5 class="mb-1">{{ profile.name }}</h5>
|
||||
<div class="text-body-secondary mb-2">{{ profile.email }}</div>
|
||||
<CBadge color="primary">{{ accessLevelLabel }}</CBadge>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="text-start">
|
||||
<div class="small text-body-secondary">Status</div>
|
||||
<div class="mb-3">{{ profile.status }}</div>
|
||||
<div class="small text-body-secondary">Tenant</div>
|
||||
<div v-if="tenants.length" class="d-flex flex-wrap gap-1 mt-1">
|
||||
<CBadge
|
||||
v-for="membership in tenants"
|
||||
:key="membership.id"
|
||||
:color="membership.is_default ? 'primary' : 'secondary'"
|
||||
>
|
||||
{{ membership.tenant?.tenant_name || `Tenant #${membership.tenant_id}` }}
|
||||
</CBadge>
|
||||
</div>
|
||||
<div v-else>-</div>
|
||||
|
||||
<div class="small text-body-secondary mt-3">Menu Group</div>
|
||||
<div v-if="menuGroups.length" class="d-flex flex-wrap gap-1 mt-1">
|
||||
<CBadge v-for="assignment in menuGroups" :key="assignment.id" color="info">
|
||||
{{ assignment.menu_group?.name || `Group #${assignment.menu_group_id}` }}
|
||||
</CBadge>
|
||||
</div>
|
||||
<div v-else>-</div>
|
||||
</div>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
|
||||
<CCol :lg="8">
|
||||
<CForm @submit.prevent="saveProfile">
|
||||
<CCard class="mb-3">
|
||||
<CCardHeader class="fw-semibold">Informasi Akun</CCardHeader>
|
||||
<CCardBody>
|
||||
<CRow class="g-3">
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Nama</CFormLabel>
|
||||
<CFormInput v-model="form.name" required />
|
||||
</CCol>
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Username</CFormLabel>
|
||||
<CFormInput v-model="form.username" autocomplete="username" />
|
||||
</CCol>
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Email</CFormLabel>
|
||||
<CFormInput v-model="form.email" type="email" required />
|
||||
</CCol>
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Nomor WhatsApp</CFormLabel>
|
||||
<CFormInput v-model="form.whatsapp_number" />
|
||||
</CCol>
|
||||
</CRow>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
|
||||
<CCard class="mb-3">
|
||||
<CCardHeader class="fw-semibold">Data Profile</CCardHeader>
|
||||
<CCardBody>
|
||||
<CRow class="g-3">
|
||||
<CCol :md="6">
|
||||
<CFormLabel>NIK</CFormLabel>
|
||||
<CFormInput v-model="form.nik" />
|
||||
</CCol>
|
||||
<CCol :xs="12">
|
||||
<CFormLabel>Alamat</CFormLabel>
|
||||
<CFormTextarea v-model="form.address" rows="3" />
|
||||
</CCol>
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Provinsi</CFormLabel>
|
||||
<CFormSelect
|
||||
v-model="form.provinsi_id"
|
||||
:disabled="wilayahLoading.provinsi"
|
||||
@change="onProvinsiChange"
|
||||
>
|
||||
<option value="">{{ wilayahLoading.provinsi ? 'Memuat provinsi...' : 'Pilih provinsi' }}</option>
|
||||
<option v-for="row in wilayahOptions.provinsi" :key="row.id" :value="String(row.id)">
|
||||
{{ row.kode }} — {{ row.nama }}
|
||||
</option>
|
||||
</CFormSelect>
|
||||
</CCol>
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Kabupaten</CFormLabel>
|
||||
<CFormSelect
|
||||
v-model="form.kabupaten_id"
|
||||
:disabled="!form.provinsi_id || wilayahLoading.kabupaten"
|
||||
@change="onKabupatenChange"
|
||||
>
|
||||
<option value="">{{ wilayahLoading.kabupaten ? 'Memuat kabupaten...' : 'Pilih kabupaten' }}</option>
|
||||
<option v-for="row in wilayahOptions.kabupaten" :key="row.id" :value="String(row.id)">
|
||||
{{ row.kode }} — {{ row.nama }}
|
||||
</option>
|
||||
</CFormSelect>
|
||||
</CCol>
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Kecamatan</CFormLabel>
|
||||
<CFormSelect
|
||||
v-model="form.kecamatan_id"
|
||||
:disabled="!form.kabupaten_id || wilayahLoading.kecamatan"
|
||||
@change="onKecamatanChange"
|
||||
>
|
||||
<option value="">{{ wilayahLoading.kecamatan ? 'Memuat kecamatan...' : 'Pilih kecamatan' }}</option>
|
||||
<option v-for="row in wilayahOptions.kecamatan" :key="row.id" :value="String(row.id)">
|
||||
{{ row.kode }} — {{ row.nama }}
|
||||
</option>
|
||||
</CFormSelect>
|
||||
</CCol>
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Desa</CFormLabel>
|
||||
<CFormSelect
|
||||
v-model="form.desa_id"
|
||||
:disabled="!form.kecamatan_id || wilayahLoading.desa"
|
||||
>
|
||||
<option value="">{{ wilayahLoading.desa ? 'Memuat desa...' : 'Pilih desa' }}</option>
|
||||
<option v-for="row in wilayahOptions.desa" :key="row.id" :value="String(row.id)">
|
||||
{{ row.kode }} — {{ row.nama }}
|
||||
</option>
|
||||
</CFormSelect>
|
||||
</CCol>
|
||||
</CRow>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
|
||||
<CCard class="mb-3">
|
||||
<CCardHeader class="fw-semibold">Ubah Password</CCardHeader>
|
||||
<CCardBody>
|
||||
<p class="small text-body-secondary">Kosongkan jika tidak ingin mengubah password.</p>
|
||||
<CRow class="g-3">
|
||||
<CCol :md="4">
|
||||
<CFormLabel>Password Saat Ini</CFormLabel>
|
||||
<CFormInput v-model="form.current_password" type="password" autocomplete="current-password" />
|
||||
</CCol>
|
||||
<CCol :md="4">
|
||||
<CFormLabel>Password Baru</CFormLabel>
|
||||
<CFormInput v-model="form.password" type="password" autocomplete="new-password" />
|
||||
</CCol>
|
||||
<CCol :md="4">
|
||||
<CFormLabel>Konfirmasi Password</CFormLabel>
|
||||
<CFormInput v-model="form.password_confirmation" type="password" autocomplete="new-password" />
|
||||
</CCol>
|
||||
</CRow>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
|
||||
<div class="d-flex justify-content-end">
|
||||
<CButton color="primary" type="submit" :disabled="saving">
|
||||
{{ saving ? 'Menyimpan...' : 'Simpan Perubahan' }}
|
||||
</CButton>
|
||||
</div>
|
||||
</CForm>
|
||||
</CCol>
|
||||
</CRow>
|
||||
</div>
|
||||
</CCol>
|
||||
</CRow>
|
||||
</template>
|
||||
@@ -0,0 +1,369 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import BaseResponsiveDataView from '@/components/base/BaseResponsiveDataView.vue'
|
||||
import {
|
||||
getTenants,
|
||||
createTenant,
|
||||
getTenantById,
|
||||
updateTenant,
|
||||
deleteTenant,
|
||||
} from '@/services/tenantService'
|
||||
import { formatTanggal } from '@/utils/tglindo.js'
|
||||
import { showConfirm, showSuccess, showError } from '@/utils/swal.js'
|
||||
|
||||
const loading = ref(true)
|
||||
const loadedOnce = ref(false)
|
||||
const saving = ref(false)
|
||||
const tenants = ref([])
|
||||
const pagination = ref({ current_page: 1, last_page: 1, per_page: 10, total: 0 })
|
||||
const currentQuery = ref({ page: 1, per_page: 10, sort_by: 'id', sort_direction: 'desc' })
|
||||
|
||||
const modal = ref(false)
|
||||
const detailModal = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editingId = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
tenant_code: '',
|
||||
tenant_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
const detailItem = ref(null)
|
||||
|
||||
const columns = [
|
||||
{ key: 'tenant_code', label: 'Code' },
|
||||
{ key: 'tenant_name', label: 'Name' },
|
||||
{ key: 'phone', label: 'Phone', sortable: false },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'status_label', label: 'Status', sortKey: 'status' },
|
||||
]
|
||||
|
||||
const tableActions = [
|
||||
{ key: 'detail', label: 'Detail', color: 'primary' },
|
||||
{ key: 'edit', label: 'Edit', color: 'success' },
|
||||
{ key: 'delete', label: 'Delete', color: 'danger' },
|
||||
]
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
tenant_code: '',
|
||||
tenant_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
status: 'active',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTenant(item = {}) {
|
||||
const status = item.status ?? 'inactive'
|
||||
return {
|
||||
id: item.id ?? '-',
|
||||
tenant_code: item.tenant_code ?? '-',
|
||||
tenant_name: item.tenant_name ?? '-',
|
||||
phone: item.phone ?? '-',
|
||||
email: item.email ?? '-',
|
||||
address: item.address ?? '-',
|
||||
status,
|
||||
status_label: status === 'active' ? 'Aktif' : 'Nonaktif',
|
||||
created_at: item.created_at ?? null,
|
||||
updated_at: item.updated_at ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllData(params = currentQuery.value) {
|
||||
currentQuery.value = { ...currentQuery.value, ...params }
|
||||
params = currentQuery.value
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getTenants(params)
|
||||
const payload = Array.isArray(res?.data?.data)
|
||||
? res.data.data
|
||||
: Array.isArray(res?.data)
|
||||
? res.data
|
||||
: Array.isArray(res)
|
||||
? res
|
||||
: []
|
||||
tenants.value = payload.map(normalizeTenant)
|
||||
const meta = res?.data && !Array.isArray(res.data) ? res.data : {}
|
||||
pagination.value = {
|
||||
current_page: meta.current_page || params.page || 1,
|
||||
last_page: meta.last_page || 1,
|
||||
per_page: meta.per_page || params.per_page || 10,
|
||||
total: meta.total || payload.length,
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err?.response?.data?.message || 'Gagal memuat data tenant: ' + err.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadedOnce.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAllData()
|
||||
})
|
||||
|
||||
function openAddModal() {
|
||||
isEdit.value = false
|
||||
editingId.value = null
|
||||
Object.assign(form, emptyForm())
|
||||
modal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(item) {
|
||||
isEdit.value = true
|
||||
editingId.value = item.id
|
||||
Object.assign(form, {
|
||||
tenant_code: item.tenant_code || '',
|
||||
tenant_name: item.tenant_name || '',
|
||||
phone: item.phone || '',
|
||||
email: item.email || '',
|
||||
address: item.address || '',
|
||||
status: item.status || 'active',
|
||||
})
|
||||
modal.value = true
|
||||
}
|
||||
|
||||
async function openDetail(item) {
|
||||
try {
|
||||
const res = await getTenantById(item.id)
|
||||
const payload = Array.isArray(res?.data) ? res.data[0] : res?.data
|
||||
detailItem.value = normalizeTenant(payload || item)
|
||||
detailModal.value = true
|
||||
} catch (err) {
|
||||
showError(err?.response?.data?.message || 'Gagal memuat detail tenant: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
if (!form.tenant_code.trim()) {
|
||||
showError('Tenant code wajib diisi.')
|
||||
return false
|
||||
}
|
||||
if (!form.tenant_name.trim()) {
|
||||
showError('Tenant name wajib diisi.')
|
||||
return false
|
||||
}
|
||||
if (!['active', 'inactive'].includes(form.status)) {
|
||||
showError('Status harus active atau inactive.')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function formPayload() {
|
||||
return {
|
||||
tenant_code: form.tenant_code.trim(),
|
||||
tenant_name: form.tenant_name.trim(),
|
||||
phone: form.phone?.trim() || null,
|
||||
email: form.email?.trim() || null,
|
||||
address: form.address?.trim() || null,
|
||||
status: form.status,
|
||||
}
|
||||
}
|
||||
|
||||
async function createItem() {
|
||||
saving.value = true
|
||||
try {
|
||||
await createTenant(formPayload())
|
||||
modal.value = false
|
||||
showSuccess('Tenant berhasil dibuat.')
|
||||
await fetchAllData()
|
||||
} catch (err) {
|
||||
showError(err?.response?.data?.message || 'Gagal membuat tenant: ' + err.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateItem() {
|
||||
if (!editingId.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await updateTenant(editingId.value, formPayload())
|
||||
modal.value = false
|
||||
showSuccess('Tenant berhasil diupdate.')
|
||||
await fetchAllData()
|
||||
} catch (err) {
|
||||
showError(err?.response?.data?.message || 'Gagal mengupdate tenant: ' + err.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDeleteModal(item) {
|
||||
const result = await showConfirm(
|
||||
'Konfirmasi Hapus',
|
||||
`Apakah Anda yakin ingin menghapus tenant "${item.tenant_name}"?`,
|
||||
)
|
||||
if (!result.isConfirmed) return
|
||||
|
||||
try {
|
||||
await deleteTenant(item.id)
|
||||
showSuccess('Tenant berhasil dihapus.')
|
||||
await fetchAllData()
|
||||
} catch (err) {
|
||||
showError(err?.response?.data?.message || 'Gagal menghapus tenant: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveItem() {
|
||||
if (!validateForm()) return
|
||||
if (isEdit.value) {
|
||||
await updateItem()
|
||||
} else {
|
||||
await createItem()
|
||||
}
|
||||
}
|
||||
|
||||
function onActionClick({ action, item }) {
|
||||
if (action === 'detail') openDetail(item)
|
||||
if (action === 'edit') openEditModal(item)
|
||||
if (action === 'delete') openDeleteModal(item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CRow class="brdvx-page-row g-0 m-0">
|
||||
<CCol :xs="12" class="brdvx-page-column p-0">
|
||||
<div class="brdvx-page-container">
|
||||
<div class="brdvx-page-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<h5 class="mb-0 fw-semibold">Tenants</h5>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<CButton color="primary" size="sm" variant="outline" :disabled="loading" @click="fetchAllData()">
|
||||
{{ loading ? 'Loading...' : 'Refresh' }}
|
||||
</CButton>
|
||||
<CButton color="primary" size="sm" @click="openAddModal">+ Tambah Tenant</CButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !loadedOnce" class="brdvx-loading-state" role="status" aria-live="polite">
|
||||
<CSpinner color="primary" />
|
||||
<p class="brdvx-loading-text">Memuat data...</p>
|
||||
</div>
|
||||
|
||||
<BaseResponsiveDataView
|
||||
v-else
|
||||
:columns="columns"
|
||||
:items="tenants"
|
||||
server-side
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
initial-sort-by="id"
|
||||
initial-sort-direction="desc"
|
||||
:actions="tableActions"
|
||||
row-key="id"
|
||||
@action-click="onActionClick"
|
||||
@query-change="fetchAllData"
|
||||
/>
|
||||
</div>
|
||||
</CCol>
|
||||
</CRow>
|
||||
|
||||
<CModal :visible="modal" @close="modal = false" alignment="center">
|
||||
<CModalHeader dismiss @close="modal = false">
|
||||
<CModalTitle>{{ isEdit ? 'Edit Tenant' : 'Tambah Tenant' }}</CModalTitle>
|
||||
</CModalHeader>
|
||||
<CModalBody>
|
||||
<CForm>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Tenant Code</CFormLabel>
|
||||
<CFormInput v-model="form.tenant_code" placeholder="Contoh: TNT-001" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Tenant Name</CFormLabel>
|
||||
<CFormInput v-model="form.tenant_name" placeholder="Nama tenant" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Phone</CFormLabel>
|
||||
<CFormInput v-model="form.phone" placeholder="Nomor telepon (opsional)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Email</CFormLabel>
|
||||
<CFormInput v-model="form.email" type="email" placeholder="Email (opsional)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Address</CFormLabel>
|
||||
<CFormTextarea v-model="form.address" rows="3" placeholder="Alamat (opsional)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Status</CFormLabel>
|
||||
<CFormSelect v-model="form.status">
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</CFormSelect>
|
||||
</div>
|
||||
</CForm>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" size="sm" @click="modal = false" :disabled="saving">Batal</CButton>
|
||||
<CButton color="primary" size="sm" @click="saveItem" :disabled="saving">
|
||||
<span v-if="saving">Menyimpan...</span>
|
||||
<span v-else>Simpan</span>
|
||||
</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
|
||||
<CModal :visible="detailModal" @close="detailModal = false" alignment="center">
|
||||
<CModalHeader dismiss @close="detailModal = false">
|
||||
<CModalTitle>Detail Tenant</CModalTitle>
|
||||
</CModalHeader>
|
||||
<CModalBody>
|
||||
<CTable bordered small v-if="detailItem">
|
||||
<CTableBody>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell style="width: 40%">ID</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.id }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Tenant Code</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.tenant_code }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Tenant Name</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.tenant_name }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Phone</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.phone }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Email</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.email }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Address</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.address }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Status</CTableHeaderCell>
|
||||
<CTableDataCell>
|
||||
<CBadge :color="detailItem.status === 'active' ? 'success' : 'secondary'">
|
||||
{{ detailItem.status === 'active' ? 'Aktif' : 'Nonaktif' }}
|
||||
</CBadge>
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Created At</CTableHeaderCell>
|
||||
<CTableDataCell>{{ formatTanggal(detailItem.created_at) }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Updated At</CTableHeaderCell>
|
||||
<CTableDataCell>{{ formatTanggal(detailItem.updated_at) }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
</CTableBody>
|
||||
</CTable>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" size="sm" @click="detailModal = false">Tutup</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useTicketStore } from '@/stores/ticket.js'
|
||||
|
||||
const store = useTicketStore()
|
||||
|
||||
const approvedTickets = computed(() =>
|
||||
store.tickets.filter((t) => t.approved_by && t.approved_by !== '-')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CRow>
|
||||
<CCol :xs="12">
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Approved Tickets</strong>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<div class="table-responsive">
|
||||
<CTable striped hover small>
|
||||
<CTableHead>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell scope="col">No</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Ticket No</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Kode Tiket</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Deskripsi</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Approved By</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Approved At</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Status</CTableHeaderCell>
|
||||
</CTableRow>
|
||||
</CTableHead>
|
||||
<CTableBody>
|
||||
<CTableRow v-for="(item, index) in approvedTickets" :key="item.ticket_no">
|
||||
<CTableHeaderCell scope="row">{{ index + 1 }}</CTableHeaderCell>
|
||||
<CTableDataCell>{{ item.ticket_no }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.kode_tiket }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.deskripsi }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.approved_by }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.approved_at }}</CTableDataCell>
|
||||
<CTableDataCell>
|
||||
<CBadge color="success">Approved</CBadge>
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow v-if="approvedTickets.length === 0">
|
||||
<CTableDataCell colspan="7" class="text-center">Tidak ada data approved tickets</CTableDataCell>
|
||||
</CTableRow>
|
||||
</CTableBody>
|
||||
</CTable>
|
||||
</div>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
</CRow>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useTicketStore } from '@/stores/ticket.js'
|
||||
|
||||
const store = useTicketStore()
|
||||
|
||||
const rejectedTickets = computed(() =>
|
||||
store.tickets.filter((t) => t.rejected_by && t.rejected_by !== '-')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CRow>
|
||||
<CCol :xs="12">
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Rejected Tickets</strong>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<div class="table-responsive">
|
||||
<CTable striped hover small>
|
||||
<CTableHead>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell scope="col">No</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Ticket No</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Kode Tiket</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Deskripsi</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Rejected By</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Rejected At</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Alasan</CTableHeaderCell>
|
||||
</CTableRow>
|
||||
</CTableHead>
|
||||
<CTableBody>
|
||||
<CTableRow v-for="(item, index) in rejectedTickets" :key="item.ticket_no">
|
||||
<CTableHeaderCell scope="row">{{ index + 1 }}</CTableHeaderCell>
|
||||
<CTableDataCell>{{ item.ticket_no }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.kode_tiket }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.deskripsi }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.rejected_by }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.rejected_at }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.rejection_reason }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow v-if="rejectedTickets.length === 0">
|
||||
<CTableDataCell colspan="7" class="text-center">Tidak ada data rejected tickets</CTableDataCell>
|
||||
</CTableRow>
|
||||
</CTableBody>
|
||||
</CTable>
|
||||
</div>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
</CRow>
|
||||
</template>
|
||||
@@ -0,0 +1,565 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useTicketStore } from '@/stores/ticket.js'
|
||||
|
||||
const store = useTicketStore()
|
||||
const loading = ref(false)
|
||||
|
||||
function capitalize(str) {
|
||||
if (!str) return ''
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
const token = 'Bearer uZ1vM4UON3CsDV9niGD1gLS4sHpCxT9nzadkITmc6caf2ea2'
|
||||
|
||||
async function fetchTickets() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch('https://api.radiq.my.id/api/tickets', {
|
||||
headers: { Authorization: token },
|
||||
})
|
||||
const json = await res.json()
|
||||
const mapped = (json.data?.data || []).map((item) => ({
|
||||
ticket_no: item.ticket_no,
|
||||
tenant_id: item.tenant_id,
|
||||
customer_id: item.customer_id,
|
||||
deskripsi: item.description,
|
||||
kode_tiket: item.title,
|
||||
status: capitalize(item.status),
|
||||
prioritas: capitalize(item.priority),
|
||||
sla_minutes: item.sla_minutes,
|
||||
ditugaskan_untuk: '',
|
||||
created_by: item.created_by,
|
||||
approved_by: item.approved_by || '',
|
||||
approved_at: item.approved_at || '',
|
||||
rejected_by: item.rejected_by || '',
|
||||
rejected_at: item.rejected_at || '',
|
||||
rejection_reason: item.rejection_reason || '',
|
||||
assigned_at: item.assigned_at || '',
|
||||
resolved_at: item.resolved_at || '',
|
||||
closed_at: item.closed_at || '',
|
||||
created_at: item.created_at || '',
|
||||
updated_at: item.updated_at || '',
|
||||
}))
|
||||
store.setTickets(mapped)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch tickets:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchTickets)
|
||||
|
||||
const ticketModal = ref(false)
|
||||
const detailModal = ref(false)
|
||||
const approveModal = ref(false)
|
||||
const rejectModal = ref(false)
|
||||
const editingIndex = ref(-1)
|
||||
const isEdit = ref(false)
|
||||
const form = reactive({
|
||||
ticket_no: '',
|
||||
tenant_id: '',
|
||||
customer_id: '',
|
||||
deskripsi: '',
|
||||
kode_tiket: '',
|
||||
status: 'Open',
|
||||
prioritas: 'Low',
|
||||
sla_minutes: '',
|
||||
ditugaskan_untuk: '',
|
||||
created_by: '',
|
||||
approved_by: '',
|
||||
approved_at: '',
|
||||
rejected_by: '',
|
||||
rejected_at: '',
|
||||
rejection_reason: '',
|
||||
assigned_at: '',
|
||||
resolved_at: '',
|
||||
closed_at: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
})
|
||||
|
||||
const emptyForm = () => ({
|
||||
ticket_no: '',
|
||||
tenant_id: '',
|
||||
customer_id: '',
|
||||
deskripsi: '',
|
||||
kode_tiket: '',
|
||||
status: 'Open',
|
||||
prioritas: 'Low',
|
||||
sla_minutes: '',
|
||||
ditugaskan_untuk: '',
|
||||
created_by: '',
|
||||
approved_by: '',
|
||||
approved_at: '',
|
||||
rejected_by: '',
|
||||
rejected_at: '',
|
||||
rejection_reason: '',
|
||||
assigned_at: '',
|
||||
resolved_at: '',
|
||||
closed_at: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
})
|
||||
|
||||
const detailItem = ref(null)
|
||||
const approveIndex = ref(-1)
|
||||
const rejectIndex = ref(-1)
|
||||
const approverName = ref('')
|
||||
const rejectorName = ref('')
|
||||
const rejectReason = ref('')
|
||||
|
||||
function openAddModal() {
|
||||
isEdit.value = false
|
||||
editingIndex.value = -1
|
||||
Object.assign(form, emptyForm())
|
||||
ticketModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(index) {
|
||||
isEdit.value = true
|
||||
editingIndex.value = index
|
||||
Object.assign(form, store.tickets[index])
|
||||
ticketModal.value = true
|
||||
}
|
||||
|
||||
function openDetail(item) {
|
||||
detailItem.value = item
|
||||
detailModal.value = true
|
||||
}
|
||||
|
||||
async function saveTicket() {
|
||||
const body = {
|
||||
ticket_no: form.ticket_no,
|
||||
tenant_id: form.tenant_id,
|
||||
customer_id: form.customer_id,
|
||||
title: form.kode_tiket,
|
||||
description: form.deskripsi,
|
||||
priority: form.prioritas?.toLowerCase(),
|
||||
status: form.status?.toLowerCase(),
|
||||
sla_minutes: form.sla_minutes,
|
||||
}
|
||||
try {
|
||||
if (isEdit.value && editingIndex.value >= 0) {
|
||||
const res = await fetch(`https://api.radiq.my.id/api/tickets/${form.ticket_no}`, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (res.ok) store.updateTicket(editingIndex.value, form)
|
||||
} else {
|
||||
const res = await fetch('https://api.radiq.my.id/api/tickets', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
store.addTicket(json.data || form)
|
||||
}
|
||||
}
|
||||
ticketModal.value = false
|
||||
} catch (err) {
|
||||
console.error('Save ticket failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function deleteTicket(index) {
|
||||
store.deleteTicket(index)
|
||||
}
|
||||
|
||||
function openApproveModal(index) {
|
||||
approveIndex.value = index
|
||||
approverName.value = ''
|
||||
approveModal.value = true
|
||||
}
|
||||
|
||||
async function confirmApprove() {
|
||||
if (!approverName.value.trim()) return
|
||||
const ticket = store.tickets[approveIndex.value]
|
||||
try {
|
||||
const res = await fetch(`https://api.radiq.my.id/api/tickets/${ticket.ticket_no}/approve`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ approved_by: approverName.value.trim() }),
|
||||
})
|
||||
if (res.ok) {
|
||||
store.approveTicket(approveIndex.value, approverName.value.trim())
|
||||
approveModal.value = false
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Approve failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function openRejectModal(index) {
|
||||
rejectIndex.value = index
|
||||
rejectorName.value = ''
|
||||
rejectReason.value = ''
|
||||
rejectModal.value = true
|
||||
}
|
||||
|
||||
function confirmReject() {
|
||||
if (rejectorName.value.trim() && rejectReason.value.trim()) {
|
||||
store.rejectTicket(rejectIndex.value, rejectorName.value.trim(), rejectReason.value.trim())
|
||||
rejectModal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isPending(item) {
|
||||
return (
|
||||
(item.approved_by === '-' || !item.approved_by) &&
|
||||
(item.rejected_by === '-' || !item.rejected_by)
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CRow>
|
||||
<CCol :xs="12">
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Ticket</strong>
|
||||
<CButton color="primary" class="float-end" @click="openAddModal">
|
||||
+ Tambah Ticket
|
||||
</CButton>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<div class="table-responsive">
|
||||
<CTable striped hover small>
|
||||
<CTableHead>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell scope="col">No</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col" class="text-center">Ticket No</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col" class="text-center w-25">Title</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Deskripsi</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Status</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Prioritas</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col" class="text-center"
|
||||
>Ditugaskan Untuk</CTableHeaderCell
|
||||
>
|
||||
<CTableHeaderCell scope="col" class="text-center">Aksi</CTableHeaderCell>
|
||||
</CTableRow>
|
||||
</CTableHead>
|
||||
<CTableBody>
|
||||
<CTableRow v-for="(item, index) in store.tickets" :key="item.ticket_no">
|
||||
<CTableHeaderCell scope="row">{{ index + 1 }}</CTableHeaderCell>
|
||||
<CTableDataCell class="text-center">{{ item.ticket_no }}</CTableDataCell>
|
||||
<CTableDataCell class="text-center w-25">{{ item.kode_tiket }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.deskripsi }}</CTableDataCell>
|
||||
<CTableDataCell>
|
||||
<CBadge
|
||||
:color="
|
||||
item.status === 'Closed'
|
||||
? 'success'
|
||||
: item.status === 'Approved'
|
||||
? 'info'
|
||||
: item.status === 'Rejected'
|
||||
? 'dark'
|
||||
: 'primary'
|
||||
"
|
||||
>
|
||||
{{ item.status }}
|
||||
</CBadge>
|
||||
</CTableDataCell>
|
||||
<CTableDataCell>
|
||||
<CBadge
|
||||
:color="
|
||||
item.prioritas === 'High'
|
||||
? 'danger'
|
||||
: item.prioritas === 'Medium'
|
||||
? 'warning'
|
||||
: 'secondary'
|
||||
"
|
||||
>
|
||||
{{ item.prioritas }}
|
||||
</CBadge>
|
||||
</CTableDataCell>
|
||||
<CTableDataCell class="text-center">{{ item.ditugaskan_untuk }}</CTableDataCell>
|
||||
<CTableDataCell class="text-center">
|
||||
<CButton color="info" size="sm" class="me-1" @click="openDetail(item)"
|
||||
>Detail</CButton
|
||||
>
|
||||
<CButton
|
||||
v-if="isPending(item)"
|
||||
color="success"
|
||||
size="sm"
|
||||
class="me-1"
|
||||
@click="openApproveModal(index)"
|
||||
>Setujui</CButton
|
||||
>
|
||||
<CButton
|
||||
v-if="isPending(item)"
|
||||
color="warning"
|
||||
size="sm"
|
||||
class="me-1"
|
||||
@click="openRejectModal(index)"
|
||||
>Tolak</CButton
|
||||
>
|
||||
<CButton color="warning" size="sm" class="me-1" @click="openEditModal(index)"
|
||||
>Edit</CButton
|
||||
>
|
||||
<CButton color="danger" size="sm" @click="deleteTicket(index)">Hapus</CButton>
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
</CTableBody>
|
||||
</CTable>
|
||||
</div>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
</CRow>
|
||||
|
||||
<!-- Form Modal (Add / Edit) -->
|
||||
<CModal :visible="ticketModal" @close="ticketModal = false" size="lg" alignment="center">
|
||||
<CModalHeader dismiss @close="ticketModal = false">
|
||||
<CModalTitle>{{ isEdit ? 'Edit Ticket' : 'Tambah Ticket' }}</CModalTitle>
|
||||
</CModalHeader>
|
||||
<CModalBody>
|
||||
<CForm>
|
||||
<CRow class="mb-3">
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Ticket No</CFormLabel>
|
||||
<CFormInput v-model="form.ticket_no" />
|
||||
</CCol>
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Tenant ID</CFormLabel>
|
||||
<CFormInput v-model="form.tenant_id" />
|
||||
</CCol>
|
||||
</CRow>
|
||||
<CRow class="mb-3">
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Customer ID</CFormLabel>
|
||||
<CFormInput v-model="form.customer_id" />
|
||||
</CCol>
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Kode Tiket</CFormLabel>
|
||||
<CFormInput v-model="form.kode_tiket" />
|
||||
</CCol>
|
||||
</CRow>
|
||||
<CRow class="mb-3">
|
||||
<CCol :md="12">
|
||||
<CFormLabel>Deskripsi</CFormLabel>
|
||||
<CFormTextarea v-model="form.deskripsi" rows="2"></CFormTextarea>
|
||||
</CCol>
|
||||
</CRow>
|
||||
<CRow class="mb-3">
|
||||
<CCol :md="4">
|
||||
<CFormLabel>Status</CFormLabel>
|
||||
<CFormSelect v-model="form.status">
|
||||
<option value="Open">Open</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Closed">Closed</option>
|
||||
</CFormSelect>
|
||||
</CCol>
|
||||
<CCol :md="4">
|
||||
<CFormLabel>Prioritas</CFormLabel>
|
||||
<CFormSelect v-model="form.prioritas">
|
||||
<option value="Low">Low</option>
|
||||
<option value="Medium">Medium</option>
|
||||
<option value="High">High</option>
|
||||
<option value="Critical">Critical</option>
|
||||
</CFormSelect>
|
||||
</CCol>
|
||||
<CCol :md="4">
|
||||
<CFormLabel>SLA (menit)</CFormLabel>
|
||||
<CFormInput v-model="form.sla_minutes" type="number" />
|
||||
</CCol>
|
||||
</CRow>
|
||||
<CRow class="mb-3">
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Ditugaskan Untuk</CFormLabel>
|
||||
<CFormInput v-model="form.ditugaskan_untuk" />
|
||||
</CCol>
|
||||
<CCol :md="6">
|
||||
<CFormLabel>Created By</CFormLabel>
|
||||
<CFormInput v-model="form.created_by" />
|
||||
</CCol>
|
||||
</CRow>
|
||||
</CForm>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="ticketModal = false">Batal</CButton>
|
||||
<CButton color="primary" @click="saveTicket">Simpan</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
|
||||
<!-- Approve Modal -->
|
||||
<CModal :visible="approveModal" @close="approveModal = false" alignment="center">
|
||||
<CModalHeader dismiss @close="approveModal = false">
|
||||
<CModalTitle>Setujui Ticket</CModalTitle>
|
||||
</CModalHeader>
|
||||
<CModalBody>
|
||||
<CForm>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Nama Leader / SPV</CFormLabel>
|
||||
<CFormInput v-model="approverName" placeholder="Masukkan nama" />
|
||||
</div>
|
||||
</CForm>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="approveModal = false">Batal</CButton>
|
||||
<CButton color="success" @click="confirmApprove" :disabled="!approverName.trim()"
|
||||
>Setujui</CButton
|
||||
>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
|
||||
<!-- Reject Modal -->
|
||||
<CModal :visible="rejectModal" @close="rejectModal = false" alignment="center">
|
||||
<CModalHeader dismiss @close="rejectModal = false">
|
||||
<CModalTitle>Tolak Ticket</CModalTitle>
|
||||
</CModalHeader>
|
||||
<CModalBody>
|
||||
<CForm>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Nama Leader / SPV</CFormLabel>
|
||||
<CFormInput v-model="rejectorName" placeholder="Masukkan nama" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Alasan Penolakan</CFormLabel>
|
||||
<CFormTextarea
|
||||
v-model="rejectReason"
|
||||
rows="3"
|
||||
placeholder="Masukkan alasan"
|
||||
></CFormTextarea>
|
||||
</div>
|
||||
</CForm>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="rejectModal = false">Batal</CButton>
|
||||
<CButton
|
||||
color="warning"
|
||||
@click="confirmReject"
|
||||
:disabled="!rejectorName.trim() || !rejectReason.trim()"
|
||||
>Tolak</CButton
|
||||
>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
|
||||
<!-- Detail Modal -->
|
||||
<CModal :visible="detailModal" @close="detailModal = false" size="lg" alignment="center">
|
||||
<CModalHeader dismiss @close="detailModal = false">
|
||||
<CModalTitle>Detail Ticket</CModalTitle>
|
||||
</CModalHeader>
|
||||
<CModalBody>
|
||||
<CTable bordered small v-if="detailItem">
|
||||
<CTableBody>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell style="width: 30%">Ticket No</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.ticket_no }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Tenant ID</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.tenant_id }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Customer ID</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.customer_id }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Kode Tiket</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.kode_tiket }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Deskripsi</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.deskripsi }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Status</CTableHeaderCell>
|
||||
<CTableDataCell>
|
||||
<CBadge
|
||||
:color="
|
||||
detailItem.status === 'Closed'
|
||||
? 'success'
|
||||
: detailItem.status === 'Approved'
|
||||
? 'info'
|
||||
: detailItem.status === 'Rejected'
|
||||
? 'dark'
|
||||
: 'primary'
|
||||
"
|
||||
>
|
||||
{{ detailItem.status }}
|
||||
</CBadge>
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Prioritas</CTableHeaderCell>
|
||||
<CTableDataCell>
|
||||
<CBadge
|
||||
:color="
|
||||
detailItem.prioritas === 'High'
|
||||
? 'danger'
|
||||
: detailItem.prioritas === 'Medium'
|
||||
? 'warning'
|
||||
: 'secondary'
|
||||
"
|
||||
>
|
||||
{{ detailItem.prioritas }}
|
||||
</CBadge>
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>SLA (menit)</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.sla_minutes }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Ditugaskan Untuk</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.ditugaskan_untuk }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Created By</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.created_by }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Approved By</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.approved_by || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Approved At</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.approved_at || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Rejected By</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.rejected_by || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Rejected At</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.rejected_at || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Rejection Reason</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.rejection_reason || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Assigned At</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.assigned_at || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Resolved At</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.resolved_at || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Closed At</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.closed_at || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Created At</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.created_at || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Updated At</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.updated_at || '-' }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
</CTableBody>
|
||||
</CTable>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="detailModal = false">Tutup</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
</template>
|
||||
@@ -0,0 +1,341 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useTicketIncidentStore } from '@/stores/ticketIncident.js'
|
||||
import { formatTanggal } from '@/utils/tglindo.js'
|
||||
import { showConfirm, showSuccess, showError } from '@/utils/swal.js'
|
||||
import { CButton } from '@coreui/vue'
|
||||
import api from '@/api/axios'
|
||||
|
||||
const store = useTicketIncidentStore()
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchTicketIncidentTypes() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/ticket-incident-types')
|
||||
store.setItems(response.data.data || [])
|
||||
} catch (err) {
|
||||
showError('Gagal memuat data: ' + err.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchTicketIncidentTypes)
|
||||
|
||||
const modal = ref(false)
|
||||
const detailModal = ref(false)
|
||||
const editingIndex = ref(-1)
|
||||
const isEdit = ref(false)
|
||||
const deleteIndex = ref(-1)
|
||||
const saving = ref(false)
|
||||
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(10)
|
||||
|
||||
const totalPages = computed(() => Math.ceil(store.items.length / perPage.value))
|
||||
|
||||
const paginatedItems = computed(() => {
|
||||
const start = (currentPage.value - 1) * perPage.value
|
||||
return store.items.slice(start, start + perPage.value)
|
||||
})
|
||||
|
||||
function goToPage(page) {
|
||||
if (page >= 1 && page <= totalPages.value) currentPage.value = page
|
||||
}
|
||||
|
||||
function changePerPage(val) {
|
||||
perPage.value = val
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
is_active: '1',
|
||||
})
|
||||
|
||||
const emptyForm = () => ({
|
||||
name: '',
|
||||
is_active: '1',
|
||||
})
|
||||
|
||||
const detailItem = ref(null)
|
||||
|
||||
function openAddModal() {
|
||||
isEdit.value = false
|
||||
editingIndex.value = -1
|
||||
Object.assign(form, emptyForm())
|
||||
modal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(item) {
|
||||
const idx = store.items.findIndex((t) => t.id === item.id)
|
||||
if (idx === -1) return
|
||||
isEdit.value = true
|
||||
editingIndex.value = idx
|
||||
Object.assign(form, {
|
||||
name: item.name,
|
||||
is_active: item.is_active ? '1' : '0',
|
||||
})
|
||||
modal.value = true
|
||||
}
|
||||
|
||||
function openDetail(item) {
|
||||
getTicketIncidentType(item.id)
|
||||
}
|
||||
|
||||
async function openDeleteModal(item) {
|
||||
const idx = store.items.findIndex((t) => t.id === item.id)
|
||||
if (idx === -1) return
|
||||
const result = await showConfirm(
|
||||
'Konfirmasi Hapus',
|
||||
'Apakah Anda yakin ingin menghapus ticket incident ini?',
|
||||
)
|
||||
if (!result.isConfirmed) return
|
||||
deleteIndex.value = idx
|
||||
await deleteTicketIncidentType(item.id)
|
||||
}
|
||||
|
||||
async function createTicketIncidentType() {
|
||||
saving.value = true
|
||||
const body = {
|
||||
name: form.name,
|
||||
is_active: form.is_active === '1',
|
||||
}
|
||||
try {
|
||||
const response = await api.post('/ticket-incident-types', body)
|
||||
store.addItem(response.data.data)
|
||||
modal.value = false
|
||||
currentPage.value = totalPages.value
|
||||
showSuccess('Data berhasil disimpan')
|
||||
fetchTicketIncidentTypes()
|
||||
} catch (err) {
|
||||
showError(err.response?.data?.message || 'Gagal menyimpan data: ' + err.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getTicketIncidentType(id) {
|
||||
try {
|
||||
const response = await api.get(`/ticket-incident-types/${id}`)
|
||||
detailItem.value = response.data.data
|
||||
detailModal.value = true
|
||||
} catch (err) {
|
||||
showError('Gagal memuat detail data: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTicketIncidentType(id) {
|
||||
saving.value = true
|
||||
const body = {
|
||||
name: form.name,
|
||||
is_active: form.is_active === '1',
|
||||
}
|
||||
try {
|
||||
const response = await api.put(`/ticket-incident-types/${id}`, body)
|
||||
store.updateItem(editingIndex.value, response.data.data)
|
||||
modal.value = false
|
||||
showSuccess('Data Berhasil Di Simpan')
|
||||
fetchTicketIncidentTypes()
|
||||
} catch (err) {
|
||||
showError(err.response?.data?.message || 'Gagal mengupdate data: ' + err.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTicketIncidentType(id) {
|
||||
try {
|
||||
await api.delete(`/ticket-incident-types/${id}`)
|
||||
store.deleteItem(deleteIndex.value)
|
||||
if (paginatedItems.length === 0 && currentPage.value > 1) currentPage.value--
|
||||
showSuccess('Data berhasil dihapus')
|
||||
fetchTicketIncidentTypes()
|
||||
} catch (err) {
|
||||
showError('Gagal menghapus data: ' + err.message)
|
||||
} finally {
|
||||
deleteIndex.value = -1
|
||||
}
|
||||
}
|
||||
|
||||
async function saveItem() {
|
||||
if (isEdit.value && editingIndex.value >= 0) {
|
||||
const item = store.items[editingIndex.value]
|
||||
await updateTicketIncidentType(item.id)
|
||||
} else {
|
||||
await createTicketIncidentType()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CRow>
|
||||
<CCol :xs="12">
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader>
|
||||
<strong>Ticket Incident</strong>
|
||||
<CButton color="primary" class="float-end" @click="openAddModal">
|
||||
+ Tambah Ticket Incident
|
||||
</CButton>
|
||||
</CCardHeader>
|
||||
<CCardBody>
|
||||
<div class="">
|
||||
<CTable striped hover small>
|
||||
<CTableHead>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell scope="col">No</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Nama</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Status</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col" class="text-center">Created At</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col" class="text-center">Updated At</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col" class="text-center">Aksi</CTableHeaderCell>
|
||||
</CTableRow>
|
||||
</CTableHead>
|
||||
<CTableBody>
|
||||
<CTableRow v-for="(item, index) in paginatedItems" :key="item.id">
|
||||
<CTableHeaderCell scope="row">{{
|
||||
(currentPage - 1) * perPage + index + 1
|
||||
}}</CTableHeaderCell>
|
||||
<CTableDataCell>{{ item.name }}</CTableDataCell>
|
||||
<CTableDataCell>
|
||||
<CBadge :color="item.is_active ? 'success' : 'secondary'">
|
||||
{{ item.is_active ? 'Active' : 'Inactive' }}
|
||||
</CBadge>
|
||||
</CTableDataCell>
|
||||
<CTableDataCell class="text-center">{{
|
||||
formatTanggal(item.created_at)
|
||||
}}</CTableDataCell>
|
||||
<CTableDataCell class="text-center">{{
|
||||
formatTanggal(item.updated_at)
|
||||
}}</CTableDataCell>
|
||||
<CTableDataCell class="text-center">
|
||||
<CButton color="info" size="sm" class="me-1" @click="openDetail(item)">
|
||||
Detail
|
||||
</CButton>
|
||||
<CButton color="warning" size="sm" class="me-1" @click="openEditModal(item)">
|
||||
Edit
|
||||
</CButton>
|
||||
<CButton color="danger" size="sm" @click="openDeleteModal(item)">
|
||||
Hapus
|
||||
</CButton>
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
</CTableBody>
|
||||
</CTable>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span>Tampil</span>
|
||||
<CFormSelect
|
||||
:model-value="perPage"
|
||||
style="width: auto"
|
||||
@change="changePerPage(Number($event.target.value))"
|
||||
>
|
||||
<option :value="10">10</option>
|
||||
<option :value="15">15</option>
|
||||
<option :value="25">25</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</CFormSelect>
|
||||
<span>dari {{ store.items.length }} data</span>
|
||||
</div>
|
||||
<CPagination v-if="totalPages > 1" aria-label="pagination">
|
||||
<CPaginationItem
|
||||
aria-label="Previous"
|
||||
:disabled="currentPage === 1"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
Previous
|
||||
</CPaginationItem>
|
||||
<CPaginationItem
|
||||
v-for="page in totalPages"
|
||||
:key="page"
|
||||
:active="page === currentPage"
|
||||
@click="goToPage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</CPaginationItem>
|
||||
<CPaginationItem
|
||||
aria-label="Next"
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
Next
|
||||
</CPaginationItem>
|
||||
</CPagination>
|
||||
</div>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
</CRow>
|
||||
|
||||
<!-- Add / Edit Modal -->
|
||||
<CModal :visible="modal" @close="modal = false" alignment="center">
|
||||
<CModalHeader dismiss @close="modal = false">
|
||||
<CModalTitle>{{ isEdit ? 'Edit Ticket Incident' : 'Tambah Ticket Incident' }}</CModalTitle>
|
||||
</CModalHeader>
|
||||
<CModalBody>
|
||||
<CForm>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Name</CFormLabel>
|
||||
<CFormInput v-model="form.name" placeholder="Contoh: Kabel putus" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<CFormLabel>Status</CFormLabel>
|
||||
<CFormSelect v-model="form.is_active">
|
||||
<option value="1">Active</option>
|
||||
<option value="0">Inactive</option>
|
||||
</CFormSelect>
|
||||
</div>
|
||||
</CForm>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="modal = false" :disabled="saving">Batal</CButton>
|
||||
<CButton color="primary" @click="saveItem" :disabled="saving">
|
||||
<span v-if="saving">Menyimpan...</span>
|
||||
<span v-else>Simpan</span>
|
||||
</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
|
||||
<!-- Detail Modal -->
|
||||
<CModal :visible="detailModal" @close="detailModal = false" alignment="center">
|
||||
<CModalHeader dismiss @close="detailModal = false">
|
||||
<CModalTitle>Detail Ticket Incident</CModalTitle>
|
||||
</CModalHeader>
|
||||
<CModalBody>
|
||||
<CTable bordered small v-if="detailItem">
|
||||
<CTableBody>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell style="width: 40%">ID</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.id }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Name</CTableHeaderCell>
|
||||
<CTableDataCell>{{ detailItem.name }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Status</CTableHeaderCell>
|
||||
<CTableDataCell>
|
||||
<CBadge :color="detailItem.is_active ? 'success' : 'secondary'">
|
||||
{{ detailItem.is_active ? 'Active' : 'Inactive' }}
|
||||
</CBadge>
|
||||
</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Created At</CTableHeaderCell>
|
||||
<CTableDataCell>{{ formatTanggal(detailItem.created_at) }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell>Updated At</CTableHeaderCell>
|
||||
<CTableDataCell>{{ formatTanggal(detailItem.updated_at) }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
</CTableBody>
|
||||
</CTable>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="detailModal = false">Tutup</CButton>
|
||||
</CModalFooter>
|
||||
</CModal>
|
||||
</template>
|
||||
@@ -0,0 +1,431 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import Swal from 'sweetalert2'
|
||||
import { useMaterialStore } from '@/stores/materialStore'
|
||||
import { materialService } from '@/services/materialService'
|
||||
|
||||
import MaterialTable from '@/components/materials/MaterialTable.vue'
|
||||
import MaterialHistory from '@/components/materials/MaterialHistory.vue'
|
||||
import AssignMaterialModal from '@/components/materials/AssignMaterialModal.vue'
|
||||
import TransferMaterialModal from '@/components/materials/TransferMaterialModal.vue'
|
||||
import ReturnMaterialModal from '@/components/materials/ReturnMaterialModal.vue'
|
||||
import MaterialDetailModal from '@/components/materials/MaterialDetailModal.vue'
|
||||
|
||||
const store = useMaterialStore()
|
||||
|
||||
const currentTab = ref('user')
|
||||
const searchBarcode = ref('')
|
||||
const searchMaterial = ref('')
|
||||
const selectedUser = ref(null)
|
||||
const selectedBarcode = ref('')
|
||||
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(15)
|
||||
|
||||
const filterType = ref('')
|
||||
|
||||
const assignModal = ref(false)
|
||||
const transferModal = ref(false)
|
||||
const returnModal = ref(false)
|
||||
const detailModal = ref(false)
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
const assignForm = reactive({
|
||||
user_id: null,
|
||||
barcode_id: '',
|
||||
material_name: '',
|
||||
type: 'serialized',
|
||||
serial_number: '',
|
||||
qty: 1,
|
||||
unit: '',
|
||||
})
|
||||
|
||||
const transferForm = reactive({
|
||||
barcode_id: '',
|
||||
from_user_id: null,
|
||||
to_user_id: null,
|
||||
})
|
||||
|
||||
const returnForm = reactive({
|
||||
barcode_id: '',
|
||||
user_id: null,
|
||||
})
|
||||
|
||||
const detailItem = ref({})
|
||||
|
||||
const filteredMaterials = computed(() => {
|
||||
return store.materials.filter((item) => {
|
||||
const barcodeMatch =
|
||||
!searchBarcode.value ||
|
||||
item.barcode_id?.toLowerCase().includes(searchBarcode.value.toLowerCase())
|
||||
|
||||
const materialMatch =
|
||||
!searchMaterial.value ||
|
||||
item.material_name?.toLowerCase().includes(searchMaterial.value.toLowerCase())
|
||||
|
||||
const userMatch = !selectedUser.value || item.user_id == selectedUser.value
|
||||
|
||||
const typeMatch = !filterType.value || item.type === filterType.value
|
||||
|
||||
return barcodeMatch && materialMatch && userMatch && typeMatch
|
||||
})
|
||||
})
|
||||
|
||||
const isFilteringActive = computed(() => {
|
||||
return (
|
||||
searchBarcode.value !== '' ||
|
||||
searchMaterial.value !== '' ||
|
||||
selectedUser.value !== null ||
|
||||
filterType.value !== ''
|
||||
)
|
||||
})
|
||||
|
||||
const currentMaterialsSource = computed(() => {
|
||||
return isFilteringActive.value ? filteredMaterials.value : store.materials
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.ceil(currentMaterialsSource.value.length / perPage.value))
|
||||
|
||||
const paginatedMaterials = computed(() => {
|
||||
const start = (currentPage.value - 1) * perPage.value
|
||||
return currentMaterialsSource.value.slice(start, start + perPage.value)
|
||||
})
|
||||
|
||||
const assignedMaterial = computed(
|
||||
() => store.materials.filter((i) => i.status === 'Assigned').length,
|
||||
)
|
||||
const returnedMaterial = computed(
|
||||
() => store.materials.filter((i) => i.status === 'Returned').length,
|
||||
)
|
||||
const serializedMaterial = computed(
|
||||
() => store.materials.filter((i) => i.type === 'serialized').length,
|
||||
)
|
||||
const consumableMaterial = computed(
|
||||
() => store.materials.filter((i) => i.type === 'consumable').length,
|
||||
)
|
||||
|
||||
const totalMaterial = computed(() => store.materials.length)
|
||||
|
||||
watch(searchBarcode, () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
watch(searchMaterial, () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
watch(filterType, () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
watch([selectedUser], async ([newSelectedUser]) => {
|
||||
currentPage.value = 1
|
||||
if (newSelectedUser) {
|
||||
await store.fetchByUser(newSelectedUser)
|
||||
} else {
|
||||
await store.fetchAll()
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedBarcode, async (val) => {
|
||||
if (val) await store.fetchHistory(val)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchAll()
|
||||
})
|
||||
|
||||
function openTransferModal(item) {
|
||||
transferForm.barcode_id = item.barcode_id
|
||||
transferForm.from_user_id = item.user_id
|
||||
transferForm.to_user_id = null
|
||||
transferModal.value = true
|
||||
}
|
||||
|
||||
function openReturnModal(item) {
|
||||
returnForm.barcode_id = item.barcode_id
|
||||
returnForm.user_id = item.user_id
|
||||
returnModal.value = true
|
||||
}
|
||||
|
||||
function openDetail(item) {
|
||||
detailItem.value = { ...item }
|
||||
detailModal.value = true
|
||||
}
|
||||
|
||||
async function assignMaterial() {
|
||||
saving.value = true
|
||||
try {
|
||||
await materialService.assign({
|
||||
...assignForm,
|
||||
qty: Number(assignForm.qty),
|
||||
})
|
||||
assignModal.value = false
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Berhasil',
|
||||
text: 'Material berhasil di-assign',
|
||||
timer: 1500,
|
||||
showConfirmButton: false,
|
||||
})
|
||||
const userId = selectedUser.value || assignForm.user_id
|
||||
if (userId) {
|
||||
selectedUser.value = userId
|
||||
await store.fetchByUser(userId)
|
||||
} else {
|
||||
await store.fetchAll()
|
||||
}
|
||||
assignForm.barcode_id = ''
|
||||
assignForm.user_id = null
|
||||
assignForm.material_name = ''
|
||||
assignForm.type = 'serialized'
|
||||
assignForm.serial_number = ''
|
||||
assignForm.qty = 1
|
||||
assignForm.unit = ''
|
||||
} catch (err) {
|
||||
Swal.fire({ icon: 'error', title: 'Gagal', text: err.response?.data?.message || err.message })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function transferMaterial() {
|
||||
const confirm = await Swal.fire({
|
||||
title: 'Transfer Material?',
|
||||
text: 'Pastikan user tujuan sudah benar.',
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Transfer',
|
||||
cancelButtonText: 'Batal',
|
||||
})
|
||||
if (!confirm.isConfirmed) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await materialService.transfer({ ...transferForm })
|
||||
transferModal.value = false
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Berhasil',
|
||||
text: 'Material berhasil dipindahkan',
|
||||
timer: 1500,
|
||||
showConfirmButton: false,
|
||||
})
|
||||
if (selectedUser.value) {
|
||||
await store.fetchByUser(selectedUser.value)
|
||||
} else {
|
||||
await store.fetchAll()
|
||||
}
|
||||
if (selectedBarcode.value) await store.fetchHistory(selectedBarcode.value)
|
||||
} catch (err) {
|
||||
Swal.fire({ icon: 'error', title: 'Gagal', text: err.response?.data?.message || err.message })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function returnMaterial() {
|
||||
const confirm = await Swal.fire({
|
||||
title: 'Return Material?',
|
||||
text: 'Material akan dikembalikan.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Ya',
|
||||
})
|
||||
if (!confirm.isConfirmed) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await materialService.returnMaterial({ ...returnForm })
|
||||
returnModal.value = false
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: 'Berhasil',
|
||||
text: 'Material berhasil dikembalikan',
|
||||
timer: 1500,
|
||||
showConfirmButton: false,
|
||||
})
|
||||
if (selectedUser.value) {
|
||||
await store.fetchByUser(selectedUser.value)
|
||||
} else {
|
||||
await store.fetchAll()
|
||||
}
|
||||
if (selectedBarcode.value) await store.fetchHistory(selectedBarcode.value)
|
||||
} catch (err) {
|
||||
Swal.fire({ icon: 'error', title: 'Gagal', text: err.response?.data?.message || err.message })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CRow>
|
||||
<CCol :xs="12">
|
||||
<CCard class="mb-4">
|
||||
<CCardHeader class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>Material Management</strong>
|
||||
<div class="small text-body-secondary">
|
||||
Assign, Transfer, Return dan History Material
|
||||
</div>
|
||||
</div>
|
||||
<CButton color="primary" @click="assignModal = true">+ Tambah Material</CButton>
|
||||
</CCardHeader>
|
||||
|
||||
<CCardBody>
|
||||
<CRow class="mb-4">
|
||||
<CCol md
|
||||
><CCard class="border-start border-primary border-4"
|
||||
><CCardBody
|
||||
><div>Total Material</div>
|
||||
<h3>{{ totalMaterial }}</h3></CCardBody
|
||||
></CCard
|
||||
></CCol
|
||||
>
|
||||
<CCol md
|
||||
><CCard class="border-start border-success border-4"
|
||||
><CCardBody
|
||||
><div>Assigned</div>
|
||||
<h3>{{ assignedMaterial }}</h3></CCardBody
|
||||
></CCard
|
||||
></CCol
|
||||
>
|
||||
<CCol md
|
||||
><CCard class="border-start border-secondary border-4"
|
||||
><CCardBody
|
||||
><div>Returned</div>
|
||||
<h3>{{ returnedMaterial }}</h3></CCardBody
|
||||
></CCard
|
||||
></CCol
|
||||
>
|
||||
<CCol md
|
||||
><CCard class="border-start border-info border-4"
|
||||
><CCardBody
|
||||
><div>Serialized</div>
|
||||
<h3>{{ serializedMaterial }}</h3></CCardBody
|
||||
></CCard
|
||||
></CCol
|
||||
>
|
||||
<CCol md
|
||||
><CCard class="border-start border-warning border-4"
|
||||
><CCardBody
|
||||
><div>Consumable</div>
|
||||
<h3>{{ consumableMaterial }}</h3></CCardBody
|
||||
></CCard
|
||||
></CCol
|
||||
>
|
||||
</CRow>
|
||||
|
||||
<CTabs :active-item-key="currentTab" @change="(key) => (currentTab = key)">
|
||||
<CTabList variant="tabs">
|
||||
<CTab item-key="user">Material User</CTab>
|
||||
<CTab item-key="history">History</CTab>
|
||||
</CTabList>
|
||||
|
||||
<CTabPanel item-key="user" class="mt-4">
|
||||
<CRow class="mb-3">
|
||||
<CCol md="3"
|
||||
><CFormLabel>Barcode</CFormLabel><CFormInput v-model="searchBarcode"
|
||||
/></CCol>
|
||||
<CCol md="3"
|
||||
><CFormLabel>Material</CFormLabel><CFormInput v-model="searchMaterial"
|
||||
/></CCol>
|
||||
<CCol md="2">
|
||||
<CFormLabel>Type</CFormLabel>
|
||||
<CFormSelect v-model="filterType">
|
||||
<option value="">Semua Type</option>
|
||||
<option value="serialized">Serialized</option>
|
||||
<option value="consumable">Consumable</option>
|
||||
</CFormSelect>
|
||||
</CCol>
|
||||
<CCol md="2">
|
||||
<CFormLabel>User ID</CFormLabel>
|
||||
<CFormInput v-model="selectedUser" placeholder="Filter by User ID" />
|
||||
</CCol>
|
||||
<CCol md="2" class="d-flex align-items-end">
|
||||
<CButton
|
||||
color="secondary"
|
||||
@click="
|
||||
((searchBarcode = ''),
|
||||
(searchMaterial = ''),
|
||||
(selectedUser = null),
|
||||
(filterType = ''))
|
||||
"
|
||||
>Reset</CButton
|
||||
>
|
||||
</CCol>
|
||||
</CRow>
|
||||
|
||||
<MaterialTable
|
||||
:data="paginatedMaterials"
|
||||
:totalPages="totalPages"
|
||||
:currentPage="currentPage"
|
||||
:perPage="perPage"
|
||||
:totalItems="currentMaterialsSource.length"
|
||||
:loading="store.loading"
|
||||
@update:perPage="
|
||||
(val) => {
|
||||
perPage = val
|
||||
currentPage = 1
|
||||
}
|
||||
"
|
||||
@page="(p) => (currentPage = p)"
|
||||
@detail="openDetail"
|
||||
@transfer="openTransferModal"
|
||||
@return="openReturnModal"
|
||||
/>
|
||||
</CTabPanel>
|
||||
|
||||
<CTabPanel item-key="history" class="mt-4">
|
||||
<CRow class="mb-3">
|
||||
<CCol md="4">
|
||||
<CFormLabel>Barcode</CFormLabel>
|
||||
<CFormInput v-model="selectedBarcode" placeholder="Masukkan Barcode" />
|
||||
</CCol>
|
||||
<CCol md="2" class="d-flex align-items-end">
|
||||
<CButton color="primary" @click="store.fetchHistory(selectedBarcode)"
|
||||
>Lihat History</CButton
|
||||
>
|
||||
</CCol>
|
||||
</CRow>
|
||||
|
||||
<MaterialHistory :histories="store.histories" />
|
||||
</CTabPanel>
|
||||
</CTabs>
|
||||
</CCardBody>
|
||||
</CCard>
|
||||
</CCol>
|
||||
</CRow>
|
||||
|
||||
<AssignMaterialModal
|
||||
:visible="assignModal"
|
||||
:form="assignForm"
|
||||
:saving="saving"
|
||||
@close="assignModal = false"
|
||||
@save="assignMaterial"
|
||||
/>
|
||||
|
||||
<TransferMaterialModal
|
||||
:visible="transferModal"
|
||||
:form="transferForm"
|
||||
:saving="saving"
|
||||
@close="transferModal = false"
|
||||
@save="transferMaterial"
|
||||
/>
|
||||
|
||||
<ReturnMaterialModal
|
||||
:visible="returnModal"
|
||||
:form="returnForm"
|
||||
:saving="saving"
|
||||
@close="returnModal = false"
|
||||
@save="returnMaterial"
|
||||
/>
|
||||
|
||||
<MaterialDetailModal
|
||||
:visible="detailModal"
|
||||
:item="detailItem"
|
||||
@close="detailModal = false"
|
||||
@transfer="openTransferModal"
|
||||
@return="openReturnModal"
|
||||
/>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user