Compare commits

...

18 Commits

Author SHA1 Message Date
Wian Drs 484f89e2ee Giant Update regsiter
NPM Installation / build (16.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (16.x, windows-latest) (push) Has been cancelled
NPM Installation / build (17.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (17.x, windows-latest) (push) Has been cancelled
NPM Installation / build (18.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (18.x, windows-latest) (push) Has been cancelled
2026-08-01 11:41:31 +07:00
Wian Drs 03fe4b1fd4 Giant Update
NPM Installation / build (16.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (16.x, windows-latest) (push) Has been cancelled
NPM Installation / build (17.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (17.x, windows-latest) (push) Has been cancelled
NPM Installation / build (18.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (18.x, windows-latest) (push) Has been cancelled
2026-07-31 13:57:55 +07:00
Wian Drs 73a32380e7 base struktur update
NPM Installation / build (16.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (16.x, windows-latest) (push) Has been cancelled
NPM Installation / build (17.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (17.x, windows-latest) (push) Has been cancelled
NPM Installation / build (18.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (18.x, windows-latest) (push) Has been cancelled
2026-07-27 16:25:37 +07:00
Wian Drs d5cb197f96 Resolve merge conflict
NPM Installation / build (16.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (16.x, windows-latest) (push) Has been cancelled
NPM Installation / build (17.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (17.x, windows-latest) (push) Has been cancelled
NPM Installation / build (18.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (18.x, windows-latest) (push) Has been cancelled
2026-07-03 16:37:28 +07:00
sean bd77e1a4e7 Ticket material
NPM Installation / build (16.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (16.x, windows-latest) (push) Has been cancelled
NPM Installation / build (17.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (17.x, windows-latest) (push) Has been cancelled
NPM Installation / build (18.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (18.x, windows-latest) (push) Has been cancelled
2026-07-03 16:26:00 +07:00
Wian Drs 843b21b276 Remove generated files from repository
NPM Installation / build (16.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (16.x, windows-latest) (push) Has been cancelled
NPM Installation / build (17.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (17.x, windows-latest) (push) Has been cancelled
NPM Installation / build (18.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (18.x, windows-latest) (push) Has been cancelled
2026-06-27 09:48:22 +07:00
sean 63458f397d ticketType dan ticketInsidance
NPM Installation / build (16.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (16.x, windows-latest) (push) Has been cancelled
NPM Installation / build (17.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (17.x, windows-latest) (push) Has been cancelled
NPM Installation / build (18.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (18.x, windows-latest) (push) Has been cancelled
2026-06-27 09:28:39 +07:00
sean d57332ecf9 gitignore
NPM Installation / build (16.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (16.x, windows-latest) (push) Has been cancelled
NPM Installation / build (17.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (17.x, windows-latest) (push) Has been cancelled
NPM Installation / build (18.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (18.x, windows-latest) (push) Has been cancelled
2026-06-25 11:16:19 +07:00
sean cc038a7372 Base UI
NPM Installation / build (16.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (16.x, windows-latest) (push) Has been cancelled
NPM Installation / build (17.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (17.x, windows-latest) (push) Has been cancelled
NPM Installation / build (18.x, ubuntu-latest) (push) Has been cancelled
NPM Installation / build (18.x, windows-latest) (push) Has been cancelled
2026-06-25 11:15:13 +07:00
sean c539fe47af get ticket from api 2026-06-25 11:03:19 +07:00
sean a06d0badfc UI 2026-06-24 14:52:49 +07:00
mrholek 974ba0e3c3 refactor: updade examples style 2026-04-01 17:08:15 +02:00
mrholek 8e07f38df5 release: v5.5.0 2026-04-01 10:55:19 +02:00
mrholek e016f18ce6 update list of components 2026-04-01 10:50:54 +02:00
mrholek 0808fec88b update current year 2026-04-01 10:49:34 +02:00
mrholek 7e6a087049 docs: add AI-friendly documentation and code comments
Add comprehensive documentation for AI assistants:
- .cursorrules with project context and conventions
- ARCHITECTURE.md with technical details
- DEVELOPMENT.md with practical guides
- JSDoc comments in all JavaScript modules
- Update README.md with AI-Friendly Development section

This enables AI tools (Cursor, Claude Code, GitHub Copilot) to understand the project and generate code following CoreUI Vue patterns.
2026-04-01 10:47:02 +02:00
mrholek 970ed3a69c feat(CChip, CChipInput): add new components 2026-04-01 10:29:41 +02:00
mrholek 20c410bcac chore: update documentation banner 2026-04-01 10:29:08 +02:00
111 changed files with 21007 additions and 138 deletions
+5
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -2,6 +2,7 @@
dist/
node_modules/
tests/e2e/reports/
.vite/
# local env files
.env.local
+716
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
+42 -2
View File
@@ -1,4 +1,4 @@
# CoreUI Free Vue Admin Template [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social&logo=twitter)](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 [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social&logo=twitter)](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)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
[![@coreui coreui](https://img.shields.io/badge/@coreui%20-coreui-lightgrey.svg?style=flat-square)](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).
+40
View File
@@ -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
View File
@@ -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>
+4827
View File
File diff suppressed because it is too large Load Diff
+10 -4
View File
@@ -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

+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
+38
View File
@@ -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
+28
View File
@@ -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

+4 -6
View File
@@ -1,14 +1,12 @@
<template>
<CFooter class="px-4">
<div>
<a href="https://coreui.io" target="_blank">CoreUI</a>
<span class="ms-1"
>&copy; {{ new Date().getFullYear() }} creativeLabs.</span
>
<a href="https://manjapro.net/" target="_blank">ManjaPro</a>
<span class="ms-1">&copy; {{ 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
View File
@@ -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>
+70 -5
View File
@@ -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>
+66 -2
View File
@@ -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>
+21 -11
View File
@@ -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)),
},
)
}
},
})
+10 -1
View File
@@ -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>
+65
View File
@@ -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>
+38
View File
@@ -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">&times;</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 &copy; 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>
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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
}
+14
View File
@@ -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 }
+42
View File
@@ -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
}
+23
View File
@@ -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
}
+22
View File
@@ -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)
},
}
+36
View File
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
import api from '@/api/axios'
export async function getMyMenus() {
const { data } = await api.get('/me/menus')
return data
}
+38
View File
@@ -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
}
+104
View File
@@ -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
}
+8
View File
@@ -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
+11
View File
@@ -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
}
+15
View File
@@ -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
+26
View File
@@ -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
}
+60
View File
@@ -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
}
+26
View File
@@ -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
}
+12
View File
@@ -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
+5
View File
@@ -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
+51
View File
@@ -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
}
+203
View File
@@ -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()
},
},
})
+62
View File
@@ -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
}
},
},
})
+122
View File
@@ -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
},
},
})
+24
View File
@@ -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 }
})
+109
View File
@@ -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 }
})
+24
View File
@@ -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 }
})
+24
View File
@@ -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 }
})
+517
View File
@@ -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;
}
}
+1 -7
View File
@@ -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
View File
@@ -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;
}
+48
View File
@@ -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 }
+18
View File
@@ -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,
}),
)
}
+45
View File
@@ -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()
}
+50
View File
@@ -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',
},
})
}
+26
View File
@@ -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})`
}
+21
View File
@@ -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>
+166
View File
@@ -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>
+109
View File
@@ -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>
+176
View File
@@ -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>
+205
View File
@@ -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">&larr; 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>
+290
View File
@@ -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')">&larr; 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>
+217
View File
@@ -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>
+190
View File
@@ -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')">
&larr; 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>
+433
View File
@@ -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>
+376
View File
@@ -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 &#123;&#123;customer.name&#125;&#125;.</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
View File
@@ -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>
+31
View File
@@ -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>
+107 -5
View File
@@ -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>
+164
View File
@@ -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>
+33
View File
@@ -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>
+505
View File
@@ -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>
+369
View File
@@ -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>
+55
View File
@@ -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>
+53
View File
@@ -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>
+565
View File
@@ -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>
+341
View File
@@ -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>
+431
View File
@@ -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