init:employee app repository for running ci/cd pipeline

This commit is contained in:
adelyaou 2025-10-15 15:45:20 +07:00
commit 093d543717
37 changed files with 4323 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# Node modules
node_modules/
**/node_modules/
# Environment files
.env
**/.env
# Build output
dist/
build/
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor & OS files
.vscode/
.DS_Store
Thumbs.db
# Docker artifacts
*.tar
*.pid
# Ignore local kubeconfig or secrets
kubeconfig.yaml
secrets.yaml

102
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,102 @@
stages:
- build
- push
- deploy
variables:
DOCKER_DRIVER: overlay2
IMAGE_BACKEND: $CI_REGISTRY/$CI_PROJECT_PATH/backend
IMAGE_FRONTEND: $CI_REGISTRY/$CI_PROJECT_PATH/frontend
before_script:
- echo "Logging in to GitLab Container Registry..."
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
# BUILD BACKEND
build-backend:
stage: build
script:
- |
if [[ "$CI_COMMIT_BRANCH" == "main" ]]; then
TAG="prod-latest"
ENVIRONMENT="prod"
elif [[ "$CI_COMMIT_BRANCH" == "staging" ]]; then
TAG="staging-latest"
ENVIRONMENT="stag"
else
TAG="dev-latest"
ENVIRONMENT="dev"
fi
- echo "Building backend image for $ENVIRONMENT"
- docker build -t $IMAGE_BACKEND:$TAG employee-be
only:
- main
- staging
- dev
# BUILD FRONTEND
build-frontend:
stage: build
script:
- |
if [[ "$CI_COMMIT_BRANCH" == "main" ]]; then
TAG="prod-latest"
ENVIRONMENT="prod"
elif [[ "$CI_COMMIT_BRANCH" == "staging" ]]; then
TAG="staging-latest"
ENVIRONMENT="stag"
else
TAG="dev-latest"
ENVIRONMENT="dev"
fi
- echo "Building frontend image for $ENVIRONMENT"
- docker build -t $IMAGE_FRONTEND:$TAG employee-fe
only:
- main
- staging
- dev
# PUSH IMAGES TO REGISTRY
push-images:
stage: push
script:
- |
if [[ "$CI_COMMIT_BRANCH" == "main" ]]; then
TAG="prod-latest"
elif [[ "$CI_COMMIT_BRANCH" == "staging" ]]; then
TAG="staging-latest"
else
TAG="dev-latest"
fi
- echo "Pushing images with tag $TAG..."
- docker push $IMAGE_BACKEND:$TAG
- docker push $IMAGE_FRONTEND:$TAG
only:
- main
- staging
- dev
# DEPLOY USING KUSTOMIZE
deploy:
stage: deploy
image:
name: bitnami/kubectl:latest
entrypoint: [""]
script:
- echo "$KUBECONFIG_DATA" | base64 -d > kubeconfig.yaml
- export KUBECONFIG=$(pwd)/kubeconfig.yaml
- |
if [[ "$CI_COMMIT_BRANCH" == "main" ]]; then
ENVIRONMENT="prod"
elif [[ "$CI_COMMIT_BRANCH" == "staging" ]]; then
ENVIRONMENT="stag"
else
ENVIRONMENT="dev"
fi
- echo "Deploying to $ENVIRONMENT environment..."
- kubectl apply -k employee-manifest/overlays/$ENVIRONMENT
only:
- main
- staging
- dev

12
employee-be/dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 4000
CMD ["node", "src/server.js"]

View File

@ -0,0 +1,20 @@
const bcrypt = require('bcrypt');
const db = require('./src/models');
const User = db.User;
async function hashExistingPasswords() {
const users = await User.findAll();
for (const user of users) {
// hash hanya jika password belum di-hash
if (!user.password.startsWith('$2b$')) {
const hash = await bcrypt.hash(user.password, 10);
user.password = hash;
await user.save();
console.log(`Password user ${user.email} berhasil di-hash`);
}
}
console.log('Selesai hash semua password lama');
process.exit(0);
}
hashExistingPasswords();

1770
employee-be/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
employee-be/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "employee-be",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node src/server.js",
"dev": "nodemon src/server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"express-validator": "^7.2.1",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.1",
"mysql2": "^3.15.1",
"sequelize": "^6.37.7"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
}

View File

@ -0,0 +1,12 @@
const { Sequelize } = require('sequelize');
require('dotenv').config();
const sequelize = new Sequelize(process.env.DB_NAME, process.env.DB_USER, process.env.DB_PASS, {
host: process.env.DB_HOST,
dialect: 'mysql',
port: process.env.DB_PORT || 3306,
logging: false,
define: { timestamps: true, underscored: true }
});
module.exports = sequelize;

View File

@ -0,0 +1,28 @@
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require('../models/user.model');
require('dotenv').config();
exports.register = async (req, res) => {
try {
const { name, email, password } = req.body;
const exists = await User.findOne({ where: { email }});
if (exists) return res.status(400).json({ msg: 'Email already registered' });
const salt = await bcrypt.genSalt(parseInt(process.env.BCRYPT_SALT_ROUNDS || 10));
const hash = await bcrypt.hash(password, salt);
const user = await User.create({ name, email, password: hash });
return res.status(201).json({ id: user.id, name: user.name, email: user.email });
} catch (err) { console.error(err); res.status(500).json({ error: 'Server error' }); }
};
exports.login = async (req, res) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ where: { email }});
if (!user) return res.status(401).json({ msg: 'Invalid credentials' });
const match = await bcrypt.compare(password, user.password);
if (!match) return res.status(401).json({ msg: 'Invalid credentials' });
const token = jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES_IN || '7d' });
return res.json({ token, user: { id: user.id, name: user.name, email: user.email } });
} catch (err) { console.error(err); res.status(500).json({ error: 'Server error' }); }
};

View File

@ -0,0 +1,36 @@
const Employee = require('../models/employee.model');
exports.list = async (req, res) => {
const employees = await Employee.findAll({ order: [['id', 'DESC']] });
res.json(employees);
};
exports.get = async (req, res) => {
const emp = await Employee.findByPk(req.params.id);
if (!emp) return res.status(404).json({ msg: 'Employee not found' });
res.json(emp);
};
exports.create = async (req, res) => {
const data = req.body;
try {
const created = await Employee.create(data);
res.status(201).json(created);
} catch (err) {
res.status(400).json({ error: err.message });
}
};
exports.update = async (req, res) => {
const emp = await Employee.findByPk(req.params.id);
if (!emp) return res.status(404).json({ msg: 'Employee not found' });
await emp.update(req.body);
res.json(emp);
};
exports.delete = async (req, res) => {
const emp = await Employee.findByPk(req.params.id);
if (!emp) return res.status(404).json({ msg: 'Employee not found' });
await emp.destroy();
res.json({ msg: 'Deleted' });
};

View File

@ -0,0 +1,15 @@
const jwt = require('jsonwebtoken');
require('dotenv').config();
module.exports = (req, res, next) => {
const authHeader = req.headers['authorization'];
if (!authHeader) return res.status(401).json({ msg: 'No token provided' });
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ msg: 'Invalid/Expired token' });
}
};

View File

@ -0,0 +1,15 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const Employee = sequelize.define('Employee', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING, allowNull: false },
email: { type: DataTypes.STRING, allowNull: false, unique: true },
phone: { type: DataTypes.STRING, allowNull: true },
position: { type: DataTypes.STRING, allowNull: true },
department: { type: DataTypes.STRING, allowNull: true },
salary: { type: DataTypes.DECIMAL(10,2), allowNull: true },
hired_date: { type: DataTypes.DATEONLY, allowNull: true }
});
module.exports = Employee;

View File

@ -0,0 +1,10 @@
const sequelize = require('../config/database');
const User = require('./user.model');
const Employee = require('./employee.model');
const db = { sequelize, User, Employee };
// associations if needed
// e.g., User.hasMany(Employee);
module.exports = db;

View File

@ -0,0 +1,11 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const User = sequelize.define('User', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING, allowNull: false },
email: { type: DataTypes.STRING, allowNull: false, unique: true },
password: { type: DataTypes.STRING, allowNull: false }
});
module.exports = User;

View File

@ -0,0 +1,9 @@
const express = require('express');
const router = express.Router();
const authCtrl = require('../controllers/auth.controller');
router.post('/register', authCtrl.register);
router.post('/login', authCtrl.login);
module.exports = router;

View File

@ -0,0 +1,12 @@
const express = require('express');
const router = express.Router();
const empCtrl = require('../controllers/employee.controller');
const auth = require('../middlewares/auth.middleware');
router.get('/', auth, empCtrl.list);
router.get('/:id', auth, empCtrl.get);
router.post('/', auth, empCtrl.create);
router.put('/:id', auth, empCtrl.update);
router.delete('/:id', auth, empCtrl.delete);
module.exports = router;

29
employee-be/src/server.js Normal file
View File

@ -0,0 +1,29 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const db = require('./models');
const app = express();
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
app.use(express.json());
app.use(morgan('dev'));
app.get('/api/health', (req, res) => res.send('OK'));
// routes
app.use('/api/auth', require('./routes/auth.routes'));
app.use('/api/employees', require('./routes/employee.routes'));
// start
const PORT = process.env.PORT || 4000;
(async () => {
try {
await db.sequelize.authenticate();
console.log('DB connected');
await db.sequelize.sync({ alter: true });
app.listen(PORT, () => console.log(`Server running on ${PORT}`));
} catch (err) {
console.error('Unable to start server', err);
}
})();

24
employee-fe/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
employee-fe/README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

19
employee-fe/dockerfile Normal file
View File

@ -0,0 +1,19 @@
# Stage build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Running stage
FROM nginx:alpine
RUN sed -i 's/listen\s\+80;/listen 8080;/' /etc/nginx/conf.d/default.conf
RUN mkdir -p /var/cache/nginx /var/run /var/log/nginx && \
chown -R 1000:1000 /var/cache/nginx /var/run /var/log/nginx
USER 1000
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]

13
employee-fe/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EMS APP</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1657
employee-fe/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
employee-fe/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "employee-fe",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"animate.css": "^4.1.1",
"axios": "^1.12.2",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"sweetalert2": "^11.23.0",
"vue": "^3.5.21",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.7"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

43
employee-fe/src/App.vue Normal file
View File

@ -0,0 +1,43 @@
<template>
<div id="app">
<div v-if="authToken">
<div class="d-flex">
<nav class="sidebar p-3 vh-100 text-white">
<h4>EMS</h4>
<ul class="list-unstyled mt-4">
<li><router-link to="/dashboard" class="text-white mb-2 d-block">Dashboard</router-link></li>
<li><router-link to="/employees" class="text-white mb-2 d-block">Employees</router-link></li>
<li><a href="#" @click="logout" class="text-white">Logout</a></li>
</ul>
</nav>
<div class="content flex-fill p-4">
<router-view />
</div>
</div>
</div>
<div v-else>
<router-view />
</div>
</div>
</template>
<script setup>
import { inject } from 'vue';
import { useRouter } from 'vue-router';
const authToken = inject('authToken');
const router = useRouter();
function logout(){
localStorage.removeItem('token');
authToken.value = null;
router.push('/login');
}
</script>
<style>
.sidebar { width: 250px; background-color: #1a1a1a; }
.sidebar a { text-decoration: none; }
.sidebar a:hover { text-decoration: underline; }
.content { background-color: #121212; min-height: 100vh; color: #fff; }
</style>

View File

@ -0,0 +1,15 @@
import axios from 'axios';
import { authToken } from '../main.js';
const API = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000
});
API.interceptors.request.use(config => {
const token = authToken.value;
if(token) config.headers['Authorization'] = `Bearer ${token}`;
return config;
});
export default API;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,50 @@
<template>
<div class="card mb-3 p-3 bg-dark text-white">
<h5>{{ employee?.id ? 'Edit Employee' : 'Add Employee' }}</h5>
<form @submit.prevent="handleSubmit">
<div class="mb-2">
<label>Name</label>
<input v-model="emp.name" type="text" class="form-control" required />
</div>
<div class="mb-2">
<label>Email</label>
<input v-model="emp.email" type="email" class="form-control" required />
</div>
<div class="mb-2">
<label>Position</label>
<input v-model="emp.position" type="text" class="form-control" />
</div>
<div class="mb-2">
<label>Salary</label>
<input v-model="emp.salary" type="number" class="form-control" />
</div>
<div class="mt-2">
<button type="submit" class="btn btn-primary me-2">Save</button>
<button type="button" class="btn btn-secondary" @click="$emit('cancel')">Cancel</button>
</div>
</form>
</div>
</template>
<script>
import { ref } from 'vue';
import API from '../api/api.js';
export default {
props: ['employee'],
setup(props, { emit }){
const emp = ref({ name:'', email:'', position:'', salary:0, ...props.employee });
const handleSubmit = async () => {
if(emp.value.id){
await API.put(`/employees/${emp.value.id}`, emp.value);
} else {
await API.post('/employees', emp.value);
}
emit('saved');
};
return { emp, handleSubmit };
}
}
</script>

View File

@ -0,0 +1,41 @@
<template>
<div>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Nama</th>
<th>Email</th>
<th>Posisi</th>
<th>Gaji</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
<tr v-for="emp in employees" :key="emp.id">
<td>{{ emp.id }}</td>
<td>{{ emp.name }}</td>
<td>{{ emp.email }}</td>
<td>{{ emp.position }}</td>
<td>{{ emp.salary }}</td>
<td>
<button @click="$emit('edit', emp)" class="btn-primary">Edit</button>
<button @click="$emit('delete', emp.id)" class="btn-secondary">Hapus</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
const props = defineProps({ employees: Array })
const emit = defineEmits(['edit', 'delete'])
</script>
<style scoped>
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ccc; padding: 0.5rem; text-align: left; }
.btn-primary { background: var(--primary); color: #fff; border: none; padding: 0.3rem 0.7rem; cursor: pointer; margin-right: 0.3rem; }
.btn-secondary { background: #aaa; color: #fff; border: none; padding: 0.3rem 0.7rem; cursor: pointer; }
</style>

14
employee-fe/src/main.js Normal file
View File

@ -0,0 +1,14 @@
import { createApp, ref, provide } from 'vue';
import App from './App.vue';
import router from './router';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap';
import 'bootstrap-icons/font/bootstrap-icons.css';
import 'animate.css';
import './style.css';
export const authToken = ref(localStorage.getItem('token'));
const app = createApp(App);
app.provide('authToken', authToken);
app.use(router).mount('#app');

View File

@ -0,0 +1,24 @@
import { createRouter, createWebHistory } from 'vue-router';
import Login from '../views/login.vue';
import Dashboard from '../views/dashboard.vue';
import Employees from '../views/employees.vue';
import Register from '../views/register.vue';
const routes = [
{ path: '/', redirect: '/login' },
{ path: '/login', component: Login },
{ path: '/register', component: Register },
{ path: '/dashboard', component: Dashboard, meta: { requiresAuth: true } },
{ path: '/employees', component: Employees, meta: { requiresAuth: true } }
];
const router = createRouter({ history: createWebHistory(), routes });
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token');
if (to.meta.requiresAuth && !token) return next('/login');
if ((to.path === '/login' || to.path === '/register') && token) return next('/dashboard');
next();
});
export default router;

View File

@ -0,0 +1,9 @@
import API from '../api/api.js';
export async function login(email, password){
return (await API.post('/auth/login', { email, password })).data;
}
export async function register(data){
return (await API.post('/auth/register', data)).data;
}

32
employee-fe/src/style.css Normal file
View File

@ -0,0 +1,32 @@
:root {
--primary: #0d47a1;
--accent: #0d9488;
--muted: #6b7280;
--bg-dark: #121212;
--text-light: #ffffff;
}
body {
background-color: var(--bg-dark);
color: var(--text-light);
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
}
.btn-primary {
background-color: var(--primary);
border: none;
}
.table-dark {
background-color: #1e1e1e;
color: var(--text-light);
}
input.form-control {
background-color: #1e1e1e;
color: var(--text-light);
border: 1px solid #333;
}
.card {
background-color: #1e1e1e;
color: var(--text-light);
}
.sidebar {
background-color: #1a1a1a;
}

View File

@ -0,0 +1,6 @@
<template>
<div class="p-4 text-white" style="background-color:#121212; min-height:100vh;">
<h2>Dashboard</h2>
<p>Selamat datang di Employee Management System!</p>
</div>
</template>

View File

@ -0,0 +1,70 @@
<template>
<div class="d-flex">
<div class="content flex-grow-1 p-4">
<h3>Employees</h3>
<button class="btn btn-primary mb-3" @click="create">+ Add Employee</button>
<employee-form v-if="showForm" :employee="editing" @saved="onSaved" @cancel="showForm=false" />
<table class="table table-striped table-dark">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Position</th>
<th>Salary</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="emp in employees" :key="emp.id">
<td>{{ emp.name }}</td>
<td>{{ emp.email }}</td>
<td>{{ emp.position }}</td>
<td>{{ emp.salary }}</td>
<td>
<button class="btn btn-sm btn-warning me-1" @click="edit(emp)">Edit</button>
<button class="btn btn-sm btn-danger" @click="deleteEmp(emp.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
import EmployeeForm from '../components/employeeForm.vue';
import API from '../api/api.js';
import Swal from 'sweetalert2';
import { authToken } from '../main.js';
import { useRouter } from 'vue-router';
export default {
components: { EmployeeForm },
data(){ return { employees: [], loading:true, showForm:false, editing:null }; },
async mounted(){ await this.load(); },
methods:{
async load(){
this.loading=true;
try { this.employees = (await API.get('/employees')).data; }
finally { this.loading=false; }
},
create(){ this.editing=null; this.showForm=true; },
edit(emp){ this.editing=emp; this.showForm=true; },
async deleteEmp(id){
const confirmed = await Swal.fire({ title:'Confirm delete?', showCancelButton:true, confirmButtonColor:'#0d47a1' });
if (!confirmed.isConfirmed) return;
await API.delete(`/employees/${id}`);
Swal.fire({title:'Deleted', icon:'success', confirmButtonColor:'#0d47a1'});
this.load();
},
onSaved(){ this.showForm=false; this.load(); Swal.fire({title:'Saved', icon:'success', confirmButtonColor:'#0d47a1'}); },
logout(){ localStorage.removeItem('token'); authToken.value=null; this.$router.push('/login'); },
goDashboard(){ this.$router.push('/dashboard'); }
}
}
</script>
<style scoped>
.sidebar{ width: 250px; }
.content{ background-color: #121212; min-height:100vh; color:white; }
</style>

View File

@ -0,0 +1,57 @@
<template>
<div class="container py-5">
<div class="card mx-auto shadow" style="max-width:400px;">
<div class="card-body">
<h4 class="text-center mb-4">Sign In</h4>
<form @submit.prevent="doLogin">
<div class="mb-3">
<label>Email</label>
<input v-model="email" type="email" class="form-control" placeholder="Email" required />
</div>
<div class="mb-3">
<label>Password</label>
<input v-model="password" type="password" class="form-control" placeholder="Password" required />
</div>
<button class="btn btn-primary w-100" :disabled="loading">
<span v-if="!loading">Login</span>
<span v-else class="spinner-border spinner-border-sm"></span>
</button>
<p class="mt-2 text-center">
Belum punya akun? <router-link to="/register">Register</router-link>
</p>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { login } from '../services/auth.services.js';
import { authToken } from '../main.js';
import Swal from 'sweetalert2';
const email = ref('');
const password = ref('');
const loading = ref(false);
const router = useRouter();
const doLogin = async () => {
loading.value = true;
try {
const res = await login(email.value, password.value);
localStorage.setItem('token', res.token);
authToken.value = res.token;
Swal.fire({ title:'Logged in', icon:'success', confirmButtonColor:'#0d47a1' });
router.push('/dashboard');
} catch (err){
Swal.fire({
title:'Login failed',
text: err.response?.data?.msg || err.message,
icon:'error',
confirmButtonColor:'#0d47a1'
});
} finally { loading.value = false; }
}
</script>

View File

@ -0,0 +1,72 @@
<template>
<div class="container py-5">
<div class="card mx-auto shadow" style="max-width: 400px;">
<div class="card-body">
<h4 class="card-title mb-4 text-center">Register</h4>
<form @submit.prevent="doRegister">
<div class="mb-3">
<label>Name</label>
<input v-model="name" type="text" class="form-control" placeholder="Full Name" required />
</div>
<div class="mb-3">
<label>Email</label>
<input v-model="email" type="email" class="form-control" placeholder="Email" required />
</div>
<div class="mb-3">
<label>Password</label>
<input v-model="password" type="password" class="form-control" placeholder="Password" required />
</div>
<button class="btn btn-primary w-100" :disabled="loading">
<span v-if="!loading">Register</span>
<span v-else class="spinner-border spinner-border-sm"></span>
</button>
<p class="mt-2 text-center">
Sudah punya akun? <router-link to="/login">Login</router-link>
</p>
</form>
</div>
</div>
</div>
</template>
<script>
import { register } from '../services/auth.services.js';
import { useRouter } from 'vue-router';
import { authToken } from '../main.js';
import { ref } from 'vue';
import Swal from 'sweetalert2';
export default {
setup() {
const router = useRouter();
const name = ref('');
const email = ref('');
const password = ref('');
const loading = ref(false);
const doRegister = async () => {
loading.value = true;
try {
await register({ name: name.value, email: email.value, password: password.value });
Swal.fire({
title: 'Account created!',
icon: 'success',
confirmButtonColor: '#0d47a1'
});
router.push('/login');
} catch (err) {
Swal.fire({
title: 'Register failed',
text: err.response?.data?.msg || err.message,
icon: 'error',
confirmButtonColor: '#0d47a1'
});
} finally {
loading.value = false;
}
};
return { name, email, password, loading, doRegister };
}
}
</script>

View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})