Initial import

This commit is contained in:
Flatlogic Bot 2026-03-03 20:42:30 +00:00
commit 3dec4a7f80
23 changed files with 8173 additions and 0 deletions

9
.env.example Normal file
View 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
View File

@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

20
README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View 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
View 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
View 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
View 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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 ı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
View 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>
);
}

View 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
View 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
View 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',
},
};
});