Initial import
This commit is contained in:
commit
3dec4a7f80
9
.env.example
Normal file
9
.env.example
Normal file
@ -0,0 +1,9 @@
|
||||
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||
# AI Studio automatically injects this at runtime from user secrets.
|
||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||
|
||||
# APP_URL: The URL where this applet is hosted.
|
||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||
APP_URL="MY_APP_URL"
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
20
README.md
Normal file
20
README.md
Normal file
@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/f3131ad6-74ed-4255-8f60-224ac3d8078e
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>My Google AI Studio App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
5
metadata.json
Normal file
5
metadata.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "MUA Vardiya Yönetimi",
|
||||
"description": "MUA Gelatieri d'Italia Şube Müdürleri İçin Vardiya Planlama ve Dashboard Yönetimi",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
5842
package-lock.json
generated
Normal file
5842
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx server.ts",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.29.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.6",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"recharts": "^3.7.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"vite": "^6.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^22.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
403
server.ts
Normal file
403
server.ts
Normal file
@ -0,0 +1,403 @@
|
||||
import express from 'express';
|
||||
import { createServer as createViteServer } from 'vite';
|
||||
import cors from 'cors';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import db, { initDb } from './server/db.ts';
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'mua-super-secret-key-2026';
|
||||
|
||||
let ai: GoogleGenAI | null = null;
|
||||
function getAI() {
|
||||
if (!ai) {
|
||||
const key = process.env.GEMINI_API_KEY;
|
||||
if (!key) throw new Error('GEMINI_API_KEY is required');
|
||||
ai = new GoogleGenAI({ apiKey: key });
|
||||
}
|
||||
return ai;
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Initialize DB
|
||||
initDb();
|
||||
|
||||
// Authentication Middleware
|
||||
const authenticate = (req: any, res: any, next: any) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
req.user = decoded;
|
||||
|
||||
// Check if user still exists and has the same role/branch
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get((req.user as any).id) as any;
|
||||
if (!user) return res.status(401).json({ error: 'User not found' });
|
||||
|
||||
req.user.role = user.role;
|
||||
req.user.branch_id = user.branch_id;
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
|
||||
const requireAdmin = (req: any, res: any, next: any) => {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden: Admin only' });
|
||||
next();
|
||||
};
|
||||
|
||||
// --- API Routes ---
|
||||
|
||||
// Login
|
||||
app.post('/api/auth/login', (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as any;
|
||||
|
||||
if (!user || !bcrypt.compareSync(password, user.password)) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const token = jwt.sign({ id: user.id, role: user.role, branch_id: user.branch_id }, JWT_SECRET, { expiresIn: '12h' });
|
||||
|
||||
let branchName = null;
|
||||
if (user.branch_id) {
|
||||
const branch = db.prepare('SELECT name FROM branches WHERE id = ?').get(user.branch_id) as any;
|
||||
branchName = branch ? branch.name : null;
|
||||
}
|
||||
|
||||
res.json({ token, user: { id: user.id, email: user.email, role: user.role, branch_id: user.branch_id, branch_name: branchName } });
|
||||
});
|
||||
|
||||
// Get current user info
|
||||
app.get('/api/auth/me', authenticate, (req: any, res) => {
|
||||
const user = db.prepare('SELECT id, email, role, branch_id FROM users WHERE id = ?').get(req.user.id) as any;
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
let branchName = null;
|
||||
if (user.branch_id) {
|
||||
const branch = db.prepare('SELECT name FROM branches WHERE id = ?').get(user.branch_id) as any;
|
||||
branchName = branch ? branch.name : null;
|
||||
}
|
||||
|
||||
res.json({ user: { ...user, branch_name: branchName } });
|
||||
});
|
||||
|
||||
// Dashboard Data
|
||||
app.get('/api/dashboard', authenticate, (req: any, res) => {
|
||||
let branchId;
|
||||
if (req.user.role === 'manager') {
|
||||
branchId = req.user.branch_id;
|
||||
} else if (req.user.role === 'admin') {
|
||||
branchId = req.query.branch_id;
|
||||
if (!branchId) return res.status(400).json({ error: 'Branch ID required for admin' });
|
||||
} else {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
const { start, end } = req.query; // timestamps
|
||||
if (!start || !end) return res.status(400).json({ error: 'Missing start or end' });
|
||||
|
||||
// Get all employees for this branch
|
||||
const employees = db.prepare('SELECT * FROM employees WHERE branch_id = ?').all(branchId) as any[];
|
||||
|
||||
// Get shifts for the week
|
||||
const shifts = db.prepare('SELECT * FROM shifts WHERE branch_id = ? AND start_time >= ? AND start_time <= ?').all(branchId, start, end) as any[];
|
||||
|
||||
// Calculate total hours per employee
|
||||
const employeeHours: Record<number, number> = {};
|
||||
employees.forEach(emp => employeeHours[emp.id] = 0);
|
||||
|
||||
shifts.forEach(shift => {
|
||||
const hours = (shift.end_time - shift.start_time) / (1000 * 60 * 60);
|
||||
if (employeeHours[shift.employee_id] !== undefined) {
|
||||
employeeHours[shift.employee_id] += hours;
|
||||
}
|
||||
});
|
||||
|
||||
const chartData = employees.map(emp => ({
|
||||
name: emp.name,
|
||||
hours: employeeHours[emp.id] || 0,
|
||||
status: emp.status
|
||||
}));
|
||||
|
||||
const totalHours = Object.values(employeeHours).reduce((a, b) => a + b, 0);
|
||||
|
||||
res.json({
|
||||
totalHours,
|
||||
chartData
|
||||
});
|
||||
});
|
||||
|
||||
// Shifts
|
||||
app.get('/api/shifts', authenticate, (req: any, res) => {
|
||||
const branchId = req.user.role === 'manager' ? req.user.branch_id : req.query.branch_id;
|
||||
if (!branchId) return res.status(400).json({ error: 'Branch ID required' });
|
||||
|
||||
const { start, end } = req.query;
|
||||
if (!start || !end) return res.status(400).json({ error: 'Missing start or end' });
|
||||
|
||||
const shifts = db.prepare('SELECT * FROM shifts WHERE branch_id = ? AND start_time >= ? AND start_time <= ?').all(branchId, start, end);
|
||||
res.json(shifts);
|
||||
});
|
||||
|
||||
app.post('/api/shifts/generate', authenticate, async (req: any, res) => {
|
||||
let branchId;
|
||||
if (req.user.role === 'manager') {
|
||||
branchId = req.user.branch_id;
|
||||
} else if (req.user.role === 'admin') {
|
||||
branchId = req.body.branch_id;
|
||||
if (!branchId) return res.status(400).json({ error: 'Branch ID required for admin' });
|
||||
} else {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
const { start, end } = req.body;
|
||||
if (!start || !end) return res.status(400).json({ error: 'Missing start or end' });
|
||||
|
||||
try {
|
||||
// Get employees
|
||||
const employees = db.prepare('SELECT id, name FROM employees WHERE branch_id = ? AND status = ?').all(branchId, 'active') as any[];
|
||||
if (employees.length === 0) {
|
||||
return res.status(400).json({ error: 'No active employees found for this branch.' });
|
||||
}
|
||||
|
||||
const prompt = `
|
||||
You are an expert shift scheduling AI for a gelato shop.
|
||||
Generate a weekly shift schedule for the following employees:
|
||||
${JSON.stringify(employees)}
|
||||
|
||||
The week starts at timestamp ${start} and ends at ${end}.
|
||||
|
||||
Rules:
|
||||
1. Store hours are roughly 08:00 to 22:00.
|
||||
2. Each shift must be between 4 and 11 hours long.
|
||||
3. Each employee should work around 30-45 hours this week. Do NOT exceed 45 hours per employee.
|
||||
4. Ensure there is at least 1 person in the store at all times during store hours.
|
||||
5. Return the start_time and end_time as Unix timestamps (milliseconds).
|
||||
6. Distribute the shifts fairly.
|
||||
`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3.1-pro-preview",
|
||||
contents: prompt,
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
employee_id: { type: Type.INTEGER },
|
||||
start_time: { type: Type.NUMBER },
|
||||
end_time: { type: Type.NUMBER }
|
||||
},
|
||||
required: ["employee_id", "start_time", "end_time"]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const generatedShifts = JSON.parse(response.text || '[]');
|
||||
res.json(generatedShifts);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('AI Generation Error:', err);
|
||||
res.status(500).json({ error: 'Failed to generate shifts' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/shifts/bulk', authenticate, (req: any, res) => {
|
||||
let branchId;
|
||||
if (req.user.role === 'manager') {
|
||||
branchId = req.user.branch_id;
|
||||
} else if (req.user.role === 'admin') {
|
||||
branchId = req.body.branch_id;
|
||||
if (!branchId) return res.status(400).json({ error: 'Branch ID required for admin' });
|
||||
} else {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
const { shifts } = req.body;
|
||||
if (!Array.isArray(shifts)) return res.status(400).json({ error: 'Shifts must be an array' });
|
||||
|
||||
try {
|
||||
const insert = db.prepare('INSERT INTO shifts (employee_id, branch_id, start_time, end_time) VALUES (?, ?, ?, ?)');
|
||||
const insertMany = db.transaction((shiftsToInsert) => {
|
||||
for (const shift of shiftsToInsert) {
|
||||
insert.run(shift.employee_id, branchId, shift.start_time, shift.end_time);
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(shifts);
|
||||
res.json({ success: true, count: shifts.length });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/shifts', authenticate, (req: any, res) => {
|
||||
let branchId;
|
||||
if (req.user.role === 'manager') {
|
||||
branchId = req.user.branch_id;
|
||||
} else if (req.user.role === 'admin') {
|
||||
branchId = req.body.branch_id;
|
||||
if (!branchId) return res.status(400).json({ error: 'Branch ID required for admin' });
|
||||
} else {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
const { employee_id, start_time, end_time } = req.body;
|
||||
|
||||
if (!employee_id || !start_time || !end_time) return res.status(400).json({ error: 'Missing fields' });
|
||||
|
||||
// BR-01: Past edit restriction
|
||||
const now = Date.now();
|
||||
const startOfDay = new Date().setHours(0, 0, 0, 0);
|
||||
if (start_time < startOfDay) {
|
||||
return res.status(400).json({ error: 'Geçmiş günlere ait vardiya eklenemez veya değiştirilemez.' });
|
||||
}
|
||||
|
||||
// AC 2.5: End time must be after start time
|
||||
if (end_time <= start_time) {
|
||||
return res.status(400).json({ error: 'Vardiya bitiş saati, başlangıç saatinden önce veya aynı olamaz.' });
|
||||
}
|
||||
|
||||
// BR-02: Time constraint (4-11 hours)
|
||||
const durationHours = (end_time - start_time) / (1000 * 60 * 60);
|
||||
if (durationHours < 4 || durationHours > 11) {
|
||||
return res.status(400).json({ error: 'Vardiya bloku minimum 4 saat, maksimum 11 saat olmalıdır.' });
|
||||
}
|
||||
|
||||
// AC 2.6: Overlap check
|
||||
const existingShift = db.prepare(`
|
||||
SELECT 1 FROM shifts
|
||||
WHERE employee_id = ?
|
||||
AND ((start_time < ? AND end_time > ?) OR (start_time >= ? AND start_time < ?))
|
||||
`).get(employee_id, end_time, start_time, start_time, end_time);
|
||||
|
||||
if (existingShift) {
|
||||
return res.status(400).json({ error: 'Bu personel belirtilen saatlerde zaten planlanmış.' });
|
||||
}
|
||||
|
||||
// Insert shift
|
||||
const result = db.prepare('INSERT INTO shifts (employee_id, branch_id, start_time, end_time) VALUES (?, ?, ?, ?)').run(employee_id, branchId, start_time, end_time);
|
||||
|
||||
res.json({ id: result.lastInsertRowid, employee_id, branch_id: branchId, start_time, end_time });
|
||||
});
|
||||
|
||||
// Employees
|
||||
app.get('/api/employees', authenticate, (req: any, res) => {
|
||||
const branchId = req.user.role === 'manager' ? req.user.branch_id : req.query.branch_id;
|
||||
if (!branchId) return res.status(400).json({ error: 'Branch ID required' });
|
||||
|
||||
const employees = db.prepare('SELECT * FROM employees WHERE branch_id = ?').all(branchId);
|
||||
res.json(employees);
|
||||
});
|
||||
|
||||
// Admin Routes
|
||||
app.get('/api/admin/summary', authenticate, requireAdmin, (req, res) => {
|
||||
const { start, end } = req.query;
|
||||
if (!start || !end) return res.status(400).json({ error: 'Missing start or end' });
|
||||
|
||||
try {
|
||||
const branchesCount = db.prepare('SELECT COUNT(*) as count FROM branches').get() as { count: number };
|
||||
const employeesCount = db.prepare('SELECT COUNT(*) as count FROM employees WHERE status = ?').get('active') as { count: number };
|
||||
const shiftsCount = db.prepare('SELECT COUNT(*) as count FROM shifts WHERE start_time >= ? AND start_time <= ?').get(start, end) as { count: number };
|
||||
|
||||
res.json({
|
||||
totalBranches: branchesCount.count,
|
||||
totalActiveEmployees: employeesCount.count,
|
||||
totalPlannedShifts: shiftsCount.count
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/branches', authenticate, requireAdmin, (req, res) => {
|
||||
const branches = db.prepare('SELECT * FROM branches').all();
|
||||
res.json(branches);
|
||||
});
|
||||
|
||||
app.post('/api/admin/branches', authenticate, requireAdmin, (req, res) => {
|
||||
const { name } = req.body;
|
||||
try {
|
||||
const result = db.prepare('INSERT INTO branches (name) VALUES (?)').run(name);
|
||||
res.json({ id: result.lastInsertRowid, name });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/users', authenticate, requireAdmin, (req, res) => {
|
||||
const users = db.prepare('SELECT id, email, role, branch_id FROM users').all();
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
app.post('/api/admin/users', authenticate, requireAdmin, (req, res) => {
|
||||
const { email, password, role, branch_id } = req.body;
|
||||
try {
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const hash = bcrypt.hashSync(password, salt);
|
||||
const result = db.prepare('INSERT INTO users (email, password, role, branch_id) VALUES (?, ?, ?, ?)').run(email, hash, role, branch_id || null);
|
||||
res.json({ id: result.lastInsertRowid, email, role, branch_id });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/admin/users/:id', authenticate, requireAdmin, (req, res) => {
|
||||
const { role, branch_id } = req.body;
|
||||
try {
|
||||
db.prepare('UPDATE users SET role = ?, branch_id = ? WHERE id = ?').run(role, branch_id || null, req.params.id);
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/employees', authenticate, requireAdmin, (req, res) => {
|
||||
const { name, branch_id } = req.body;
|
||||
try {
|
||||
const result = db.prepare('INSERT INTO employees (name, branch_id) VALUES (?, ?)').run(name, branch_id);
|
||||
res.json({ id: result.lastInsertRowid, name, branch_id, status: 'active' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/admin/employees/:id', authenticate, requireAdmin, (req, res) => {
|
||||
const { status } = req.body;
|
||||
try {
|
||||
db.prepare('UPDATE employees SET status = ? WHERE id = ?').run(status, req.params.id);
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Vite middleware for development
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const vite = await createViteServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: "spa",
|
||||
});
|
||||
app.use(vite.middlewares);
|
||||
}
|
||||
|
||||
app.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
startServer();
|
||||
151
server/db.ts
Normal file
151
server/db.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const dbPath = path.join(__dirname, 'mua_vardiya.db');
|
||||
const db = new Database(dbPath);
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// Initialize database schema
|
||||
export function initDb() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS branches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('admin', 'manager')),
|
||||
branch_id INTEGER,
|
||||
FOREIGN KEY(branch_id) REFERENCES branches(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS employees (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
branch_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'passive')),
|
||||
FOREIGN KEY(branch_id) REFERENCES branches(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shifts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
employee_id INTEGER NOT NULL,
|
||||
branch_id INTEGER NOT NULL,
|
||||
start_time INTEGER NOT NULL,
|
||||
end_time INTEGER NOT NULL,
|
||||
FOREIGN KEY(employee_id) REFERENCES employees(id),
|
||||
FOREIGN KEY(branch_id) REFERENCES branches(id)
|
||||
);
|
||||
`);
|
||||
|
||||
// Seed initial admin user if not exists
|
||||
const adminExists = db.prepare('SELECT 1 FROM users WHERE role = ?').get('admin');
|
||||
if (!adminExists) {
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const hash = bcrypt.hashSync('admin123', salt);
|
||||
db.prepare('INSERT INTO users (email, password, role) VALUES (?, ?, ?)').run('admin@mua.com.tr', hash, 'admin');
|
||||
}
|
||||
|
||||
// Seed branches if none exist
|
||||
const branchCount = db.prepare('SELECT COUNT(*) as count FROM branches').get() as { count: number };
|
||||
if (branchCount.count === 0) {
|
||||
const branches = [
|
||||
'MUÀ Yenikoy (Merkez)',
|
||||
'MUÀ Bebek',
|
||||
'MUÀ Akaretler',
|
||||
'MUÀ Arnavutkoy',
|
||||
'MUÀ Resitpasa',
|
||||
'MUÀ Caddebostan',
|
||||
'MUÀ Saskinbakkal',
|
||||
'MUÀ Gokturk',
|
||||
'MUÀ Acarkent Coliseum',
|
||||
'MUÀ Fisekhane',
|
||||
'MUÀ Bakirkoy',
|
||||
'MUÀ Mall of Istanbul',
|
||||
'MUÀ Hiltown',
|
||||
"City's Nisantasi AVM",
|
||||
'MUÀ Watergarden AVM',
|
||||
"City's Istanbul AVM",
|
||||
'MUÀ Alsancak',
|
||||
'MUÀ Istinye Park Izmir',
|
||||
'MUÀ Alacati',
|
||||
'MUÀ Ergin Concept',
|
||||
'MUÀ Yahya Kaptan',
|
||||
'MUÀ Bodrum Marina',
|
||||
'MUÀ Yalikavak',
|
||||
'MUÀ Sakarya'
|
||||
];
|
||||
|
||||
const insertBranch = db.prepare('INSERT INTO branches (name) VALUES (?)');
|
||||
const insertMany = db.transaction((branchesList: string[]) => {
|
||||
for (const branch of branchesList) {
|
||||
insertBranch.run(branch);
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(branches);
|
||||
|
||||
// Seed a default manager for testing (Yenikoy Merkez - ID 1)
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const hash = bcrypt.hashSync('mudur123', salt);
|
||||
db.prepare('INSERT INTO users (email, password, role, branch_id) VALUES (?, ?, ?, ?)').run('yenikoy@mua.com.tr', hash, 'manager', 1);
|
||||
|
||||
// Seed some employees for Yenikoy
|
||||
const emp1 = db.prepare("INSERT INTO employees (name, branch_id) VALUES ('Ahmet Yılmaz', 1)").run().lastInsertRowid;
|
||||
const emp2 = db.prepare("INSERT INTO employees (name, branch_id) VALUES ('Ayşe Demir', 1)").run().lastInsertRowid;
|
||||
const emp3 = db.prepare("INSERT INTO employees (name, branch_id) VALUES ('Mehmet Kaya', 1)").run().lastInsertRowid;
|
||||
const emp4 = db.prepare("INSERT INTO employees (name, branch_id) VALUES ('Fatma Şahin', 1)").run().lastInsertRowid;
|
||||
const emp5 = db.prepare("INSERT INTO employees (name, branch_id) VALUES ('Caner Çelik', 1)").run().lastInsertRowid;
|
||||
|
||||
// Generate mock shifts for the current week
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay();
|
||||
const diffToMonday = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
|
||||
const monday = new Date(now.setDate(diffToMonday));
|
||||
monday.setHours(0, 0, 0, 0);
|
||||
|
||||
const insertShift = db.prepare('INSERT INTO shifts (employee_id, branch_id, start_time, end_time) VALUES (?, ?, ?, ?)');
|
||||
const getTs = (daysOffset: number, hour: number) => {
|
||||
const d = new Date(monday);
|
||||
d.setDate(d.getDate() + daysOffset);
|
||||
d.setHours(hour, 0, 0, 0);
|
||||
return d.getTime();
|
||||
};
|
||||
|
||||
// Monday
|
||||
insertShift.run(emp1, 1, getTs(0, 8), getTs(0, 16));
|
||||
insertShift.run(emp2, 1, getTs(0, 10), getTs(0, 18));
|
||||
insertShift.run(emp3, 1, getTs(0, 14), getTs(0, 22));
|
||||
|
||||
// Tuesday
|
||||
insertShift.run(emp1, 1, getTs(1, 8), getTs(1, 16));
|
||||
insertShift.run(emp4, 1, getTs(1, 10), getTs(1, 18));
|
||||
insertShift.run(emp5, 1, getTs(1, 14), getTs(1, 22));
|
||||
|
||||
// Wednesday
|
||||
insertShift.run(emp1, 1, getTs(2, 8), getTs(2, 18)); // 10h
|
||||
insertShift.run(emp2, 1, getTs(2, 10), getTs(2, 20)); // 10h
|
||||
insertShift.run(emp3, 1, getTs(2, 12), getTs(2, 22)); // 10h
|
||||
|
||||
// Thursday
|
||||
insertShift.run(emp1, 1, getTs(3, 8), getTs(3, 18)); // 10h
|
||||
insertShift.run(emp4, 1, getTs(3, 10), getTs(3, 20)); // 10h
|
||||
insertShift.run(emp5, 1, getTs(3, 12), getTs(3, 22)); // 10h
|
||||
|
||||
// Friday (emp1 hits 46 hours to trigger the warning AC 3.1)
|
||||
insertShift.run(emp1, 1, getTs(4, 8), getTs(4, 18)); // 10h (Total: 8+8+10+10+10 = 46)
|
||||
insertShift.run(emp2, 1, getTs(4, 10), getTs(4, 18)); // 8h
|
||||
insertShift.run(emp3, 1, getTs(4, 14), getTs(4, 22)); // 8h
|
||||
}
|
||||
}
|
||||
|
||||
export default db;
|
||||
69
src/App.tsx
Normal file
69
src/App.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import Layout from './components/Layout';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Calendar from './pages/Calendar';
|
||||
import Admin from './pages/Admin';
|
||||
import AdminOverview from './pages/AdminOverview';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
{/* Manager Routes */}
|
||||
<Route
|
||||
path="dashboard"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['manager']}>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="calendar"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['manager']}>
|
||||
<Calendar />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin Routes */}
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin']}>
|
||||
<Admin />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin-overview"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin']}>
|
||||
<AdminOverview />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
144
src/components/Layout.tsx
Normal file
144
src/components/Layout.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import React from 'react';
|
||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { LayoutDashboard, Calendar, Users, LogOut, IceCream, Store } from 'lucide-react';
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const navItems = user?.role === 'admin'
|
||||
? [
|
||||
{ name: 'Genel Bakış', href: '/admin-overview', icon: LayoutDashboard },
|
||||
{ name: 'Şube Yönetimi', href: '/admin', icon: Store },
|
||||
]
|
||||
: [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ name: 'Takvim', href: '/calendar', icon: Calendar },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-mua-bg flex">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 bg-mua-surface border-r border-mua-border flex-col shadow-sm hidden md:flex z-10">
|
||||
<div className="h-16 flex items-center px-6 border-b border-mua-border bg-mua-primary">
|
||||
<IceCream className="h-6 w-6 text-white mr-2" />
|
||||
<span className="text-white font-bold text-lg tracking-wide">MUA Gelatieri</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 py-6 flex flex-col gap-1 px-3">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`flex items-center px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||
isActive
|
||||
? 'bg-mua-primary-light text-mua-primary border border-mua-primary/20 shadow-sm'
|
||||
: 'text-mua-text-muted hover:bg-mua-bg hover:text-mua-text border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`mr-3 h-5 w-5 ${isActive ? 'text-mua-primary' : 'text-mua-text-muted/70'}`} />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-mua-border bg-mua-bg/50">
|
||||
<div className="flex items-center mb-4 px-2">
|
||||
<div className="bg-mua-surface shadow-sm rounded-full p-2 mr-3 border border-mua-border">
|
||||
<Users className="h-4 w-4 text-mua-primary" />
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-sm font-medium text-mua-text truncate">{user?.email}</p>
|
||||
<p className="text-xs text-mua-text-muted capitalize">{user?.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center px-3 py-2 text-sm font-medium text-red-600 rounded-xl hover:bg-red-50 transition-colors border border-transparent hover:border-red-100"
|
||||
>
|
||||
<LogOut className="mr-3 h-5 w-5 text-red-500" />
|
||||
Çıkış Yap
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
{/* Header */}
|
||||
<header className="h-16 bg-mua-surface border-b border-mua-border flex items-center justify-between px-4 md:px-6 shadow-sm z-10 sticky top-0">
|
||||
<div className="flex items-center md:hidden">
|
||||
<IceCream className="h-6 w-6 text-mua-primary mr-2" />
|
||||
<span className="text-mua-text font-bold text-lg tracking-wide">MUA</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center ml-auto">
|
||||
{user?.role === 'manager' && user?.branch_name && (
|
||||
<div className="flex items-center bg-mua-bg px-4 py-1.5 rounded-full border border-mua-border shadow-sm">
|
||||
<Store className="h-4 w-4 text-mua-primary mr-2 hidden sm:block" />
|
||||
<span className="text-sm font-semibold text-mua-text">
|
||||
Şube: <span className="text-mua-primary ml-1">{user.branch_name}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{user?.role === 'admin' && (
|
||||
<div className="flex items-center bg-mua-bg px-4 py-1.5 rounded-full border border-mua-border shadow-sm">
|
||||
<span className="text-sm font-semibold text-mua-text">
|
||||
Merkez Admin
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-auto p-4 md:p-6 pb-24 md:pb-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile Bottom Nav (iOS Style) */}
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-mua-surface/90 backdrop-blur-md border-t border-mua-border flex justify-around p-2 pb-safe z-50 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`flex flex-col items-center p-2 rounded-xl text-[10px] font-medium transition-all ${
|
||||
isActive ? 'text-mua-primary' : 'text-mua-text-muted'
|
||||
}`}
|
||||
>
|
||||
<div className={`p-1.5 rounded-full mb-1 ${isActive ? 'bg-mua-primary-light' : 'bg-transparent'}`}>
|
||||
<Icon className={`h-5 w-5 ${isActive ? 'text-mua-primary' : 'text-mua-text-muted/70'}`} />
|
||||
</div>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex flex-col items-center p-2 rounded-xl text-[10px] font-medium text-red-500"
|
||||
>
|
||||
<div className="p-1.5 rounded-full mb-1 bg-transparent">
|
||||
<LogOut className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
Çıkış
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/ProtectedRoute.tsx
Normal file
28
src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
allowedRoles?: ('admin' | 'manager')[];
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children, allowedRoles }: ProtectedRouteProps) {
|
||||
const { user, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center bg-[#fdfbf7]">Yükleniyor...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
if (allowedRoles && !allowedRoles.includes(user.role)) {
|
||||
// Redirect to appropriate dashboard based on role
|
||||
return <Navigate to={user.role === 'admin' ? '/admin-overview' : '/dashboard'} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
102
src/context/AuthContext.tsx
Normal file
102
src/context/AuthContext.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
role: 'admin' | 'manager';
|
||||
branch_id: number | null;
|
||||
branch_name: string | null;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
login: (token: string, user: User) => void;
|
||||
logout: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
if (token) {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser(data.user);
|
||||
} else {
|
||||
logout();
|
||||
}
|
||||
} catch (err) {
|
||||
logout();
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, [token]);
|
||||
|
||||
// AC 1.3: 12 hours inactivity logout
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
|
||||
const resetTimeout = () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
logout();
|
||||
}, 12 * 60 * 60 * 1000); // 12 hours
|
||||
};
|
||||
|
||||
if (token) {
|
||||
resetTimeout();
|
||||
window.addEventListener('mousemove', resetTimeout);
|
||||
window.addEventListener('keypress', resetTimeout);
|
||||
window.addEventListener('click', resetTimeout);
|
||||
window.addEventListener('scroll', resetTimeout);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
window.removeEventListener('mousemove', resetTimeout);
|
||||
window.removeEventListener('keypress', resetTimeout);
|
||||
window.removeEventListener('click', resetTimeout);
|
||||
window.removeEventListener('scroll', resetTimeout);
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const login = (newToken: string, newUser: User) => {
|
||||
localStorage.setItem('token', newToken);
|
||||
setToken(newToken);
|
||||
setUser(newUser);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, login, logout, loading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
36
src/index.css
Normal file
36
src/index.css
Normal file
@ -0,0 +1,36 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-mua-primary: #E85D22;
|
||||
--color-mua-primary-hover: #CC4E19;
|
||||
--color-mua-primary-light: #FCEFE9;
|
||||
--color-mua-bg: #FAF8F5;
|
||||
--color-mua-surface: #FFFFFF;
|
||||
--color-mua-text: #2D2422;
|
||||
--color-mua-text-muted: #7A6B67;
|
||||
--color-mua-border: #EAE3D9;
|
||||
--color-mua-pistachio: #8C9A74;
|
||||
--color-mua-pistachio-light: #F0F3EB;
|
||||
--color-mua-chocolate: #4A3728;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-mua-border);
|
||||
border-radius: 20px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-mua-text-muted);
|
||||
}
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
278
src/pages/Admin.tsx
Normal file
278
src/pages/Admin.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Users, Store, UserPlus, ShieldAlert, Search, ChevronRight, MapPin, UserCircle } from 'lucide-react';
|
||||
|
||||
export default function Admin() {
|
||||
const { token, user } = useAuth();
|
||||
const [branches, setBranches] = useState<any[]>([]);
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [employees, setEmployees] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [selectedBranchId, setSelectedBranchId] = useState<number | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const [newBranch, setNewBranch] = useState('');
|
||||
const [newUser, setNewUser] = useState({ email: '', password: '', role: 'manager', branch_id: '' });
|
||||
const [newEmployee, setNewEmployee] = useState({ name: '', branch_id: '' });
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [bRes, uRes, eRes] = await Promise.all([
|
||||
fetch('/api/admin/branches', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/admin/users', { headers: { Authorization: `Bearer ${token}` } }),
|
||||
fetch('/api/employees?branch_id=all', { headers: { Authorization: `Bearer ${token}` } })
|
||||
]);
|
||||
|
||||
if (bRes.ok) setBranches(await bRes.json());
|
||||
if (uRes.ok) setUsers(await uRes.json());
|
||||
if (eRes.ok) setEmployees(await eRes.json());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [token]);
|
||||
|
||||
const handleAddBranch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await fetch('/api/admin/branches', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ name: newBranch })
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewBranch('');
|
||||
fetchData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ ...newUser, branch_id: selectedBranchId })
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewUser({ email: '', password: '', role: 'manager', branch_id: '' });
|
||||
fetchData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddEmployee = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await fetch('/api/admin/employees', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ ...newEmployee, branch_id: selectedBranchId })
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewEmployee({ name: '', branch_id: '' });
|
||||
fetchData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
return <div className="p-6 text-red-600 flex items-center"><ShieldAlert className="mr-2"/> Yetkisiz Erişim</div>;
|
||||
}
|
||||
|
||||
const filteredBranches = branches.filter(b => b.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
const selectedBranch = branches.find(b => b.id === selectedBranchId);
|
||||
const branchUsers = users.filter(u => u.branch_id === selectedBranchId);
|
||||
const branchEmployees = employees.filter(e => e.branch_id === selectedBranchId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-20 md:pb-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Merkez Admin Paneli</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
|
||||
{/* Left Column: Branches List */}
|
||||
<div className="lg:col-span-4 flex flex-col gap-4">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<Store className="h-5 w-5 text-[#D97757] mr-2" />
|
||||
Şubeler
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleAddBranch} className="flex gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Yeni Şube Adı"
|
||||
value={newBranch}
|
||||
onChange={(e) => setNewBranch(e.target.value)}
|
||||
className="flex-1 border border-gray-200 rounded-xl px-3 py-2 text-sm focus:ring-2 focus:ring-[#D97757]/20 focus:border-[#D97757] transition-all"
|
||||
/>
|
||||
<button type="submit" className="px-4 py-2 bg-[#D97757] text-white rounded-xl hover:bg-[#c4684a] font-medium text-sm transition-colors">
|
||||
Ekle
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Şube Ara..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-xl pl-9 pr-3 py-2 text-sm focus:ring-2 focus:ring-[#D97757]/20 focus:border-[#D97757] transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-1 custom-scrollbar">
|
||||
{filteredBranches.map(b => (
|
||||
<button
|
||||
key={b.id}
|
||||
onClick={() => setSelectedBranchId(b.id)}
|
||||
className={`w-full text-left p-3 rounded-xl border transition-all flex items-center justify-between ${
|
||||
selectedBranchId === b.id
|
||||
? 'bg-[#D97757]/10 border-[#D97757] text-[#D97757]'
|
||||
: 'bg-white border-gray-100 hover:border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<MapPin className={`h-4 w-4 mr-2 ${selectedBranchId === b.id ? 'text-[#D97757]' : 'text-gray-400'}`} />
|
||||
<span className="font-medium text-sm">{b.name}</span>
|
||||
</div>
|
||||
<ChevronRight className={`h-4 w-4 ${selectedBranchId === b.id ? 'text-[#D97757]' : 'text-gray-300'}`} />
|
||||
</button>
|
||||
))}
|
||||
{filteredBranches.length === 0 && (
|
||||
<p className="text-center text-sm text-gray-500 py-4">Şube bulunamadı.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Selected Branch Details */}
|
||||
<div className="lg:col-span-8">
|
||||
{selectedBranchId ? (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-1">{selectedBranch?.name}</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">Şube Yönetimi</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Managers Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 pb-2">
|
||||
<h3 className="font-semibold text-gray-800 flex items-center">
|
||||
<UserCircle className="h-4 w-4 text-blue-500 mr-2" />
|
||||
Şube Müdürleri
|
||||
</h3>
|
||||
<span className="bg-blue-50 text-blue-600 text-xs font-bold px-2 py-1 rounded-full">
|
||||
{branchUsers.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAddUser} className="bg-gray-50 p-3 rounded-xl border border-gray-100 space-y-3">
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="Müdür E-posta"
|
||||
value={newUser.email}
|
||||
onChange={(e) => setNewUser({...newUser, email: e.target.value})}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
placeholder="Şifre"
|
||||
value={newUser.password}
|
||||
onChange={(e) => setNewUser({...newUser, password: e.target.value})}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
|
||||
/>
|
||||
<button type="submit" className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium text-sm transition-colors">
|
||||
Müdür Ata
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
{branchUsers.map(u => (
|
||||
<div key={u.id} className="p-3 bg-white border border-gray-100 rounded-xl flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">{u.email}</span>
|
||||
<span className="text-xs text-gray-400">ID: {u.id}</span>
|
||||
</div>
|
||||
))}
|
||||
{branchUsers.length === 0 && <p className="text-xs text-gray-500 text-center py-2">Henüz müdür atanmamış.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Employees Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 pb-2">
|
||||
<h3 className="font-semibold text-gray-800 flex items-center">
|
||||
<Users className="h-4 w-4 text-green-500 mr-2" />
|
||||
Personeller
|
||||
</h3>
|
||||
<span className="bg-green-50 text-green-600 text-xs font-bold px-2 py-1 rounded-full">
|
||||
{branchEmployees.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAddEmployee} className="bg-gray-50 p-3 rounded-xl border border-gray-100 space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Personel Adı Soyadı"
|
||||
value={newEmployee.name}
|
||||
onChange={(e) => setNewEmployee({...newEmployee, name: e.target.value})}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-green-500/20 focus:border-green-500"
|
||||
/>
|
||||
<button type="submit" className="w-full py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium text-sm transition-colors">
|
||||
Personel Ekle
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto pr-1 custom-scrollbar">
|
||||
{branchEmployees.map(emp => (
|
||||
<div key={emp.id} className="p-3 bg-white border border-gray-100 rounded-xl flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">{emp.name}</span>
|
||||
<span className={`text-[10px] px-2 py-1 rounded-full font-semibold uppercase ${emp.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{emp.status === 'active' ? 'Aktif' : 'Pasif'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{branchEmployees.length === 0 && <p className="text-xs text-gray-500 text-center py-2">Henüz personel eklenmemiş.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-12 flex flex-col items-center justify-center text-center h-full min-h-[400px]">
|
||||
<div className="bg-gray-50 p-4 rounded-full mb-4">
|
||||
<Store className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Şube Seçin</h3>
|
||||
<p className="text-sm text-gray-500 max-w-sm">
|
||||
Müdür ve personel yönetimi yapmak için sol taraftaki listeden bir şube seçin.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
src/pages/AdminOverview.tsx
Normal file
161
src/pages/AdminOverview.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Store, LayoutDashboard, Calendar as CalendarIcon, Users, Clock, UserCircle } from 'lucide-react';
|
||||
import Dashboard from './Dashboard';
|
||||
import Calendar from './Calendar';
|
||||
import PersonnelList from './PersonnelList';
|
||||
import { startOfWeek, endOfWeek } from 'date-fns';
|
||||
|
||||
export default function AdminOverview() {
|
||||
const { token } = useAuth();
|
||||
const [branches, setBranches] = useState<any[]>([]);
|
||||
const [selectedBranchId, setSelectedBranchId] = useState<number | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'dashboard' | 'calendar' | 'personnel'>('dashboard');
|
||||
const [summary, setSummary] = useState<{ totalBranches: number, totalActiveEmployees: number, totalPlannedShifts: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBranches = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/branches', { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setBranches(data);
|
||||
if (data.length > 0) setSelectedBranchId(data[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSummary = async () => {
|
||||
try {
|
||||
const currentWeekStart = startOfWeek(new Date(), { weekStartsOn: 1 }).getTime();
|
||||
const currentWeekEnd = endOfWeek(new Date(), { weekStartsOn: 1 }).getTime();
|
||||
const res = await fetch(`/api/admin/summary?start=${currentWeekStart}&end=${currentWeekEnd}`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSummary(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (token) {
|
||||
fetchBranches();
|
||||
fetchSummary();
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-20 md:pb-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold text-mua-text">Şube Raporları & Vardiyalar</h1>
|
||||
|
||||
{/* Branch Selector */}
|
||||
<div className="flex items-center bg-mua-surface border border-mua-border rounded-xl px-3 py-2 shadow-sm">
|
||||
<Store className="h-5 w-5 text-mua-text-muted mr-2" />
|
||||
<select
|
||||
value={selectedBranchId || ''}
|
||||
onChange={(e) => setSelectedBranchId(Number(e.target.value))}
|
||||
className="bg-transparent border-none focus:ring-0 text-sm font-medium text-mua-text w-full md:w-48 cursor-pointer outline-none"
|
||||
>
|
||||
{branches.map(b => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedBranchId ? (
|
||||
<div className="bg-mua-surface rounded-2xl shadow-sm border border-mua-border overflow-hidden">
|
||||
<div className="flex border-b border-mua-border">
|
||||
<button
|
||||
onClick={() => setActiveTab('dashboard')}
|
||||
className={`flex-1 py-4 text-sm font-medium flex items-center justify-center transition-colors ${
|
||||
activeTab === 'dashboard'
|
||||
? 'text-mua-primary border-b-2 border-mua-primary bg-mua-primary-light/50'
|
||||
: 'text-mua-text-muted hover:text-mua-text hover:bg-mua-bg'
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="h-4 w-4 mr-2" />
|
||||
Özet Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('calendar')}
|
||||
className={`flex-1 py-4 text-sm font-medium flex items-center justify-center transition-colors ${
|
||||
activeTab === 'calendar'
|
||||
? 'text-mua-primary border-b-2 border-mua-primary bg-mua-primary-light/50'
|
||||
: 'text-mua-text-muted hover:text-mua-text hover:bg-mua-bg'
|
||||
}`}
|
||||
>
|
||||
<CalendarIcon className="h-4 w-4 mr-2" />
|
||||
Vardiya Takvimi
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('personnel')}
|
||||
className={`flex-1 py-4 text-sm font-medium flex items-center justify-center transition-colors ${
|
||||
activeTab === 'personnel'
|
||||
? 'text-mua-primary border-b-2 border-mua-primary bg-mua-primary-light/50'
|
||||
: 'text-mua-text-muted hover:text-mua-text hover:bg-mua-bg'
|
||||
}`}
|
||||
>
|
||||
<UserCircle className="h-4 w-4 mr-2" />
|
||||
Personel & Yöneticiler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Global Summary Section (Below Tabs) */}
|
||||
{summary && (
|
||||
<div className="bg-mua-bg border-b border-mua-border p-4 md:px-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-mua-surface rounded-xl shadow-sm border border-mua-border p-4 flex items-center">
|
||||
<div className="bg-mua-primary-light p-3 rounded-xl mr-4">
|
||||
<Store className="h-5 w-5 text-mua-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-mua-text-muted mb-1">Toplam Şube</p>
|
||||
<p className="text-xl font-bold text-mua-text">{summary.totalBranches}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-mua-surface rounded-xl shadow-sm border border-mua-border p-4 flex items-center">
|
||||
<div className="bg-blue-50 p-3 rounded-xl mr-4">
|
||||
<Users className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-mua-text-muted mb-1">Tüm Şubeler - Aktif Personel</p>
|
||||
<p className="text-xl font-bold text-mua-text">{summary.totalActiveEmployees}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-mua-surface rounded-xl shadow-sm border border-mua-border p-4 flex items-center">
|
||||
<div className="bg-mua-pistachio-light p-3 rounded-xl mr-4">
|
||||
<Clock className="h-5 w-5 text-mua-pistachio" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-mua-text-muted mb-1">Tüm Şubeler - Haftalık Vardiya</p>
|
||||
<p className="text-xl font-bold text-mua-text">{summary.totalPlannedShifts}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 md:p-6 bg-mua-bg">
|
||||
{activeTab === 'dashboard' ? (
|
||||
<Dashboard branchId={selectedBranchId} />
|
||||
) : activeTab === 'calendar' ? (
|
||||
<Calendar branchId={selectedBranchId} />
|
||||
) : (
|
||||
<PersonnelList />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-mua-surface rounded-2xl shadow-sm border border-mua-border p-12 flex flex-col items-center justify-center text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-mua-primary mb-4"></div>
|
||||
<p className="text-mua-text-muted">Şubeler yükleniyor...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
436
src/pages/Calendar.tsx
Normal file
436
src/pages/Calendar.tsx
Normal file
@ -0,0 +1,436 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { startOfWeek, endOfWeek, addDays, format, isSameDay, parse, isBefore, startOfDay, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, addMonths } from 'date-fns';
|
||||
import { tr } from 'date-fns/locale';
|
||||
import { ChevronLeft, ChevronRight, Plus, X, AlertCircle, Clock, Sparkles, Calendar as CalendarIcon, CalendarDays, CalendarRange } from 'lucide-react';
|
||||
|
||||
export default function Calendar({ branchId }: { branchId?: number }) {
|
||||
const { token, user } = useAuth();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [view, setView] = useState<'day' | 'week' | 'month'>('week');
|
||||
const [shifts, setShifts] = useState<any[]>([]);
|
||||
const [employees, setEmployees] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [suggestedShifts, setSuggestedShifts] = useState<any[] | null>(null);
|
||||
|
||||
// Modal State
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [selectedEmployee, setSelectedEmployee] = useState('');
|
||||
const [startTime, setStartTime] = useState('08:00');
|
||||
const [endTime, setEndTime] = useState('17:00');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const getRange = () => {
|
||||
if (view === 'month') {
|
||||
return { start: startOfWeek(startOfMonth(currentDate), { weekStartsOn: 1 }), end: endOfWeek(endOfMonth(currentDate), { weekStartsOn: 1 }) };
|
||||
} else if (view === 'week') {
|
||||
return { start: startOfWeek(currentDate, { weekStartsOn: 1 }), end: endOfWeek(currentDate, { weekStartsOn: 1 }) };
|
||||
} else {
|
||||
return { start: startOfDay(currentDate), end: addDays(startOfDay(currentDate), 1) };
|
||||
}
|
||||
};
|
||||
|
||||
const { start: rangeStart, end: rangeEnd } = getRange();
|
||||
|
||||
const fetchShifts = async () => {
|
||||
try {
|
||||
const url = branchId
|
||||
? `/api/shifts?start=${rangeStart.getTime()}&end=${rangeEnd.getTime()}&branch_id=${branchId}`
|
||||
: `/api/shifts?start=${rangeStart.getTime()}&end=${rangeEnd.getTime()}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setShifts(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchEmployees = async () => {
|
||||
try {
|
||||
const url = branchId ? `/api/employees?branch_id=${branchId}` : '/api/employees';
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEmployees(data.filter((e: any) => e.status === 'active'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
setLoading(true);
|
||||
Promise.all([fetchShifts(), fetchEmployees()]).finally(() => setLoading(false));
|
||||
}
|
||||
}, [currentDate, token, branchId]);
|
||||
|
||||
const handlePrevWeek = () => setCurrentDate(addDays(currentDate, -7));
|
||||
const handleNextWeek = () => setCurrentDate(addDays(currentDate, 7));
|
||||
|
||||
const handleGenerateAI = async () => {
|
||||
setIsGenerating(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/shifts/generate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
start: weekStart.getTime(),
|
||||
end: weekEnd.getTime(),
|
||||
...(branchId && { branch_id: branchId })
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSuggestedShifts(data);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || 'AI planlama başarısız oldu.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Bağlantı hatası.');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveAI = async () => {
|
||||
if (!suggestedShifts) return;
|
||||
try {
|
||||
const res = await fetch('/api/shifts/bulk', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
shifts: suggestedShifts,
|
||||
...(branchId && { branch_id: branchId })
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setSuggestedShifts(null);
|
||||
fetchShifts();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || 'Kaydetme başarısız oldu.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Bağlantı hatası.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelAI = () => {
|
||||
setSuggestedShifts(null);
|
||||
};
|
||||
|
||||
const handleDayClick = (day: Date) => {
|
||||
// BR-01: Past edit restriction
|
||||
if (isBefore(day, startOfDay(new Date()))) {
|
||||
alert('Geçmiş günlere vardiya eklenemez.');
|
||||
return;
|
||||
}
|
||||
setSelectedDate(day);
|
||||
setIsModalOpen(true);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleAddShift = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!selectedDate || !selectedEmployee) {
|
||||
setError('Lütfen personel seçin.');
|
||||
return;
|
||||
}
|
||||
|
||||
const startDateTime = parse(startTime, 'HH:mm', selectedDate).getTime();
|
||||
const endDateTime = parse(endTime, 'HH:mm', selectedDate).getTime();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/shifts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
employee_id: selectedEmployee,
|
||||
start_time: startDateTime,
|
||||
end_time: endDateTime,
|
||||
...(branchId && { branch_id: branchId })
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setIsModalOpen(false);
|
||||
fetchShifts();
|
||||
} else {
|
||||
setError(data.error || 'Bir hata oluştu');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('İşlem tamamlanamadı, lütfen bağlantınızı kontrol edin');
|
||||
}
|
||||
};
|
||||
|
||||
// Generate time options (15 min intervals)
|
||||
const generateTimeOptions = () => {
|
||||
const options = [];
|
||||
for (let h = 0; h < 24; h++) {
|
||||
for (let m = 0; m < 60; m += 15) {
|
||||
const hour = h.toString().padStart(2, '0');
|
||||
const min = m.toString().padStart(2, '0');
|
||||
options.push(`${hour}:${min}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
const timeOptions = generateTimeOptions();
|
||||
|
||||
const days = [];
|
||||
let day = weekStart;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
days.push(day);
|
||||
day = addDays(day, 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button onClick={handlePrevWeek} className="p-2 hover:bg-mua-bg rounded-full transition-colors">
|
||||
<ChevronLeft className="h-5 w-5 text-mua-text-muted" />
|
||||
</button>
|
||||
<h2 className="text-xl font-bold text-mua-text">
|
||||
{format(weekStart, 'MMMM yyyy', { locale: tr })}
|
||||
</h2>
|
||||
<button onClick={handleNextWeek} className="p-2 hover:bg-mua-bg rounded-full transition-colors">
|
||||
<ChevronRight className="h-5 w-5 text-mua-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{!suggestedShifts && (
|
||||
<button
|
||||
onClick={handleGenerateAI}
|
||||
disabled={isGenerating}
|
||||
className="flex items-center justify-center px-4 py-2 bg-mua-pistachio text-white rounded-xl hover:bg-mua-pistachio/90 transition-colors font-medium text-sm shadow-sm disabled:opacity-50"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
AI ile Planla
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="flex items-center justify-center px-4 py-2 bg-mua-primary text-white rounded-xl hover:bg-mua-primary-hover transition-colors font-medium text-sm shadow-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Vardiya Ekle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-xl text-sm flex items-start border border-red-100">
|
||||
<AlertCircle className="h-5 w-5 mr-2 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestedShifts && (
|
||||
<div className="bg-mua-pistachio-light border border-mua-pistachio/30 p-4 rounded-xl flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center text-mua-text">
|
||||
<Sparkles className="h-5 w-5 text-mua-pistachio mr-2" />
|
||||
<p className="text-sm font-medium">AI tarafından önerilen vardiya planını görüntülüyorsunuz. Onaylıyor musunuz?</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button onClick={handleCancelAI} className="px-4 py-2 text-sm font-medium text-mua-text bg-white border border-mua-border rounded-xl hover:bg-mua-bg transition-colors">
|
||||
İptal
|
||||
</button>
|
||||
<button onClick={handleApproveAI} className="px-4 py-2 text-sm font-medium text-white bg-mua-pistachio rounded-xl hover:bg-mua-pistachio/90 transition-colors shadow-sm">
|
||||
Onayla ve Kaydet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-mua-surface rounded-2xl shadow-sm border border-mua-border overflow-hidden">
|
||||
<div className="overflow-x-auto custom-scrollbar">
|
||||
<div className="min-w-[800px]">
|
||||
<div className="grid grid-cols-7 border-b border-mua-border bg-mua-bg/80">
|
||||
{days.map((day, i) => (
|
||||
<div key={i} className="py-4 text-center border-r border-mua-border last:border-r-0">
|
||||
<p className="text-xs font-semibold text-mua-text-muted uppercase tracking-wider mb-1">
|
||||
{format(day, 'EEEE', { locale: tr })}
|
||||
</p>
|
||||
<div className={`inline-flex items-center justify-center w-8 h-8 rounded-full text-sm font-bold ${isSameDay(day, new Date()) ? 'bg-mua-primary text-white' : 'text-mua-text'}`}>
|
||||
{format(day, 'd')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 min-h-[500px]">
|
||||
{days.map((day, i) => {
|
||||
const dayShifts = shifts.filter(s => isSameDay(new Date(s.start_time), day));
|
||||
const daySuggestedShifts = suggestedShifts?.filter(s => isSameDay(new Date(s.start_time), day)) || [];
|
||||
const isPast = isBefore(day, startOfDay(new Date()));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`border-r border-mua-border last:border-r-0 p-3 transition-colors ${!isPast ? 'hover:bg-mua-bg cursor-pointer' : 'bg-mua-bg/30'}`}
|
||||
onClick={() => handleDayClick(day)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{/* Existing Shifts */}
|
||||
{dayShifts.map(shift => {
|
||||
const emp = employees.find(e => e.id === shift.employee_id);
|
||||
return (
|
||||
<div key={shift.id} className="bg-mua-surface border border-mua-primary/30 shadow-sm rounded-lg p-3 text-xs relative overflow-hidden group hover:border-mua-primary transition-colors">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-mua-primary"></div>
|
||||
<p className="font-bold text-mua-text truncate mb-1">{emp ? emp.name : 'Bilinmeyen'}</p>
|
||||
<p className="text-mua-primary font-medium flex items-center">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{format(new Date(shift.start_time), 'HH:mm')} - {format(new Date(shift.end_time), 'HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Suggested Shifts */}
|
||||
{daySuggestedShifts.map((shift, idx) => {
|
||||
const emp = employees.find(e => e.id === shift.employee_id);
|
||||
return (
|
||||
<div key={`sug-${idx}`} className="bg-mua-pistachio-light border border-mua-pistachio border-dashed shadow-sm rounded-lg p-3 text-xs relative overflow-hidden">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-mua-pistachio"></div>
|
||||
<p className="font-bold text-mua-text truncate mb-1">{emp ? emp.name : 'Bilinmeyen'}</p>
|
||||
<p className="text-mua-pistachio font-medium flex items-center">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{format(new Date(shift.start_time), 'HH:mm')} - {format(new Date(shift.end_time), 'HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Shift Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-mua-text/40 backdrop-blur-sm">
|
||||
<div className="bg-mua-surface rounded-2xl shadow-xl w-full max-w-md p-6 relative border border-mua-border">
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="absolute top-4 right-4 text-mua-text-muted hover:text-mua-text transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<h2 className="text-xl font-bold text-mua-text mb-4">
|
||||
Vardiya Ekle
|
||||
</h2>
|
||||
<p className="text-sm text-mua-text-muted mb-6">
|
||||
{selectedDate && format(selectedDate, 'd MMMM yyyy EEEE', { locale: tr })}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleAddShift} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-xl text-sm flex items-start border border-red-100">
|
||||
<AlertCircle className="h-5 w-5 mr-2 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-mua-text mb-1">Personel</label>
|
||||
<select
|
||||
required
|
||||
value={selectedEmployee}
|
||||
onChange={(e) => setSelectedEmployee(e.target.value)}
|
||||
className="w-full border border-mua-border rounded-xl px-3 py-2.5 bg-mua-surface focus:ring-2 focus:ring-mua-primary/20 focus:border-mua-primary outline-none transition-all"
|
||||
>
|
||||
<option value="">Seçiniz...</option>
|
||||
{employees.map(emp => (
|
||||
<option key={emp.id} value={emp.id}>{emp.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-mua-text mb-1">Başlangıç</label>
|
||||
<select
|
||||
required
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="w-full border border-mua-border rounded-xl px-3 py-2.5 bg-mua-surface focus:ring-2 focus:ring-mua-primary/20 focus:border-mua-primary outline-none transition-all"
|
||||
>
|
||||
{timeOptions.map(time => (
|
||||
<option key={`start-${time}`} value={time}>{time}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-mua-text mb-1">Bitiş</label>
|
||||
<select
|
||||
required
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="w-full border border-mua-border rounded-xl px-3 py-2.5 bg-mua-surface focus:ring-2 focus:ring-mua-primary/20 focus:border-mua-primary outline-none transition-all"
|
||||
>
|
||||
{timeOptions.map(time => (
|
||||
<option key={`end-${time}`} value={time}>{time}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-mua-text bg-mua-surface border border-mua-border rounded-xl hover:bg-mua-bg transition-colors"
|
||||
>
|
||||
İptal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-mua-primary rounded-xl hover:bg-mua-primary-hover transition-colors shadow-sm"
|
||||
>
|
||||
Kaydet
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
src/pages/Dashboard.tsx
Normal file
162
src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { startOfWeek, endOfWeek, format } from 'date-fns';
|
||||
import { tr } from 'date-fns/locale';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
ReferenceLine
|
||||
} from 'recharts';
|
||||
import { Clock, Users, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const renderCustomBarLabel = (props: any) => {
|
||||
const { x, y, width, value } = props;
|
||||
if (value > 45) {
|
||||
return (
|
||||
<g transform={`translate(${x + width / 2},${y - 14})`}>
|
||||
<circle cx="0" cy="0" r="10" fill="#FEE2E2" />
|
||||
<text x="0" y="1" fill="#DC2626" textAnchor="middle" dominantBaseline="middle" fontSize="14" fontWeight="bold">
|
||||
!
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function Dashboard({ branchId }: { branchId?: number }) {
|
||||
const { token } = useAuth();
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const currentWeekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
|
||||
const currentWeekEnd = endOfWeek(new Date(), { weekStartsOn: 1 });
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const url = branchId
|
||||
? `/api/dashboard?start=${currentWeekStart.getTime()}&end=${currentWeekEnd.getTime()}&branch_id=${branchId}`
|
||||
: `/api/dashboard?start=${currentWeekStart.getTime()}&end=${currentWeekEnd.getTime()}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (token) {
|
||||
fetchDashboardData();
|
||||
}
|
||||
}, [token, branchId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-mua-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const overLimitEmployees = data?.chartData.filter((d: any) => d.hours > 45) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||
<h1 className="text-2xl font-bold text-mua-text">Haftalık Özet</h1>
|
||||
<div className="text-sm text-mua-text-muted bg-mua-surface px-4 py-2 rounded-xl shadow-sm border border-mua-border font-medium self-start sm:self-auto">
|
||||
{format(currentWeekStart, 'd MMM', { locale: tr })} - {format(currentWeekEnd, 'd MMM yyyy', { locale: tr })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
|
||||
<div className="bg-mua-surface rounded-2xl shadow-sm border border-mua-border p-6 flex items-center transition-all hover:shadow-md">
|
||||
<div className="bg-mua-primary-light p-4 rounded-2xl mr-4">
|
||||
<Clock className="h-6 w-6 text-mua-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-mua-text-muted mb-1">Toplam Planlanan Saat</p>
|
||||
<p className="text-2xl font-bold text-mua-text">{data?.totalHours.toFixed(1)}s</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-mua-surface rounded-2xl shadow-sm border border-mua-border p-6 flex items-center transition-all hover:shadow-md">
|
||||
<div className="bg-blue-50 p-4 rounded-2xl mr-4">
|
||||
<Users className="h-6 w-6 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-mua-text-muted mb-1">Aktif Personel</p>
|
||||
<p className="text-2xl font-bold text-mua-text">
|
||||
{data?.chartData.filter((d: any) => d.status === 'active').length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-mua-surface rounded-2xl shadow-sm border border-mua-border p-6 flex items-center transition-all hover:shadow-md">
|
||||
<div className={`p-4 rounded-2xl mr-4 ${overLimitEmployees.length > 0 ? 'bg-red-50' : 'bg-mua-pistachio-light'}`}>
|
||||
<AlertTriangle className={`h-6 w-6 ${overLimitEmployees.length > 0 ? 'text-red-500' : 'text-mua-pistachio'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-mua-text-muted mb-1">45 Saat Aşımı</p>
|
||||
<p className={`text-2xl font-bold ${overLimitEmployees.length > 0 ? 'text-red-600' : 'text-mua-pistachio'}`}>
|
||||
{overLimitEmployees.length} Personel
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="bg-mua-surface rounded-2xl shadow-sm border border-mua-border p-4 md:p-6">
|
||||
<h2 className="text-lg font-semibold text-mua-text mb-6">Personel Çalışma Saatleri (Haftalık)</h2>
|
||||
<div className="h-72 md:h-80 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data?.chartData} margin={{ top: 30, right: 10, left: -20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#EAE3D9" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: '#7A6B67', fontSize: 12 }}
|
||||
dy={10}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: '#7A6B67', fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: '#FAF8F5' }}
|
||||
contentStyle={{ borderRadius: '12px', border: '1px solid #EAE3D9', boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)' }}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={45}
|
||||
stroke="#DC2626"
|
||||
strokeDasharray="4 4"
|
||||
label={{ position: 'top', value: '45 Saat Sınırı', fill: '#DC2626', fontSize: 12, fontWeight: 'bold' }}
|
||||
/>
|
||||
<Bar dataKey="hours" radius={[6, 6, 0, 0]} maxBarSize={50} label={renderCustomBarLabel}>
|
||||
{data?.chartData.map((entry: any, index: number) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.hours > 45 ? '#DC2626' : '#E85D22'} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/pages/Login.tsx
Normal file
114
src/pages/Login.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { IceCream } from 'lucide-react';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
login(data.token, data.user);
|
||||
if (data.user.role === 'admin') {
|
||||
navigate('/admin-overview');
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} else {
|
||||
setError(data.error || 'Giriş başarısız');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Bir hata oluştu. Lütfen tekrar deneyin.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-mua-bg flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-mua-primary p-4 rounded-full shadow-md">
|
||||
<IceCream className="h-12 w-12 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-mua-text tracking-tight">
|
||||
MUA Gelatieri d'Italia
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-mua-text-muted">
|
||||
Vardiya Yönetim Sistemi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-mua-surface py-8 px-4 shadow-xl sm:rounded-2xl sm:px-10 border border-mua-border">
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-100 rounded-xl p-4 flex items-center">
|
||||
<p className="text-sm text-red-600 font-medium">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-mua-text mb-1.5">
|
||||
E-posta Adresi
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="appearance-none block w-full px-4 py-3 border border-mua-border rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-mua-primary/20 focus:border-mua-primary sm:text-sm transition-all bg-mua-bg/50 text-mua-text"
|
||||
placeholder="admin@mua.com.tr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-mua-text mb-1.5">
|
||||
Şifre
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="appearance-none block w-full px-4 py-3 border border-mua-border rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-mua-primary/20 focus:border-mua-primary sm:text-sm transition-all bg-mua-bg/50 text-mua-text"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-medium text-white bg-mua-primary hover:bg-mua-primary-hover focus:outline-none focus:ring-4 focus:ring-mua-primary/20 transition-all"
|
||||
>
|
||||
Giriş Yap
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="mt-8 text-center text-xs text-mua-text-muted">
|
||||
<p>MUA Gelatieri d'Italia © {new Date().getFullYear()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/pages/PersonnelList.tsx
Normal file
85
src/pages/PersonnelList.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { User, Shield, Phone, Mail, MapPin } from 'lucide-react';
|
||||
|
||||
const mockPersonnel = [
|
||||
{ id: 1, name: 'Ahmet Yılmaz', role: 'Şube Müdürü', branch: 'Kadıköy Moda', email: 'ahmet.y@mua.com.tr', phone: '+90 555 123 4567', status: 'Aktif' },
|
||||
{ id: 2, name: 'Ayşe Demir', role: 'Personel', branch: 'Kadıköy Moda', email: 'ayse.d@mua.com.tr', phone: '+90 555 234 5678', status: 'Aktif' },
|
||||
{ id: 3, name: 'Mehmet Kaya', role: 'Personel', branch: 'Kadıköy Moda', email: 'mehmet.k@mua.com.tr', phone: '+90 555 345 6789', status: 'Aktif' },
|
||||
{ id: 4, name: 'Zeynep Çelik', role: 'Şube Müdürü', branch: 'Beşiktaş', email: 'zeynep.c@mua.com.tr', phone: '+90 555 456 7890', status: 'Aktif' },
|
||||
{ id: 5, name: 'Can Özkan', role: 'Personel', branch: 'Beşiktaş', email: 'can.o@mua.com.tr', phone: '+90 555 567 8901', status: 'Pasif' },
|
||||
{ id: 6, name: 'Elif Şahin', role: 'Personel', branch: 'Beşiktaş', email: 'elif.s@mua.com.tr', phone: '+90 555 678 9012', status: 'Aktif' },
|
||||
{ id: 7, name: 'Burak Yücel', role: 'Şube Müdürü', branch: 'Nişantaşı', email: 'burak.y@mua.com.tr', phone: '+90 555 789 0123', status: 'Aktif' },
|
||||
{ id: 8, name: 'Cemre Yıldız', role: 'Personel', branch: 'Nişantaşı', email: 'cemre.y@mua.com.tr', phone: '+90 555 890 1234', status: 'Aktif' },
|
||||
];
|
||||
|
||||
export default function PersonnelList() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold text-mua-text">Personel ve Yöneticiler (Mock Veri)</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-mua-surface rounded-2xl shadow-sm border border-mua-border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-mua-bg/80 border-b border-mua-border">
|
||||
<th className="py-4 px-6 text-xs font-semibold text-mua-text-muted uppercase tracking-wider">İsim</th>
|
||||
<th className="py-4 px-6 text-xs font-semibold text-mua-text-muted uppercase tracking-wider">Rol</th>
|
||||
<th className="py-4 px-6 text-xs font-semibold text-mua-text-muted uppercase tracking-wider">Şube</th>
|
||||
<th className="py-4 px-6 text-xs font-semibold text-mua-text-muted uppercase tracking-wider">İletişim</th>
|
||||
<th className="py-4 px-6 text-xs font-semibold text-mua-text-muted uppercase tracking-wider">Durum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-mua-border">
|
||||
{mockPersonnel.map((person) => (
|
||||
<tr key={person.id} className="hover:bg-mua-bg/50 transition-colors">
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center">
|
||||
<div className={`p-2 rounded-full mr-3 ${person.role === 'Şube Müdürü' ? 'bg-mua-primary-light text-mua-primary' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{person.role === 'Şube Müdürü' ? <Shield className="h-4 w-4" /> : <User className="h-4 w-4" />}
|
||||
</div>
|
||||
<span className="font-medium text-mua-text">{person.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
person.role === 'Şube Müdürü' ? 'bg-mua-primary/10 text-mua-primary' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{person.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center text-sm text-mua-text-muted">
|
||||
<MapPin className="h-4 w-4 mr-1.5" />
|
||||
{person.branch}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="flex items-center text-sm text-mua-text-muted">
|
||||
<Mail className="h-3.5 w-3.5 mr-1.5" />
|
||||
{person.email}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-mua-text-muted">
|
||||
<Phone className="h-3.5 w-3.5 mr-1.5" />
|
||||
{person.phone}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
person.status === 'Aktif' ? 'bg-mua-pistachio-light text-mua-pistachio' : 'bg-red-50 text-red-600'
|
||||
}`}>
|
||||
{person.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
24
vite.config.ts
Normal file
24
vite.config.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig, loadEnv} from 'vite';
|
||||
|
||||
export default defineConfig(({mode}) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
define: {
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
},
|
||||
};
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user