diff --git a/.perm_test_apache b/.perm_test_apache new file mode 100644 index 0000000..e69de29 diff --git a/.perm_test_exec b/.perm_test_exec new file mode 100644 index 0000000..e69de29 diff --git a/ai/LocalAIApi.php b/ai/LocalAIApi.php new file mode 100644 index 0000000..00b1b00 --- /dev/null +++ b/ai/LocalAIApi.php @@ -0,0 +1,311 @@ + [ +// ['role' => 'system', 'content' => 'You are a helpful assistant.'], +// ['role' => 'user', 'content' => 'Tell me a bedtime story.'], +// ], +// ]); +// if (!empty($response['success'])) { +// $decoded = LocalAIApi::decodeJsonFromResponse($response); +// } + +class LocalAIApi +{ + /** @var array|null */ + private static ?array $configCache = null; + + /** + * Signature compatible with the OpenAI Responses API. + * + * @param array $params Request body (model, input, text, reasoning, metadata, etc.). + * @param array $options Extra options (timeout, verify_tls, headers, path, project_uuid). + * @return array{ + * success:bool, + * status?:int, + * data?:mixed, + * error?:string, + * response?:mixed, + * message?:string + * } + */ + public static function createResponse(array $params, array $options = []): array + { + $cfg = self::config(); + $payload = $params; + + if (empty($payload['input']) || !is_array($payload['input'])) { + return [ + 'success' => false, + 'error' => 'input_missing', + 'message' => 'Parameter "input" is required and must be an array.', + ]; + } + + if (!isset($payload['model']) || $payload['model'] === '') { + $payload['model'] = $cfg['default_model']; + } + + return self::request($options['path'] ?? null, $payload, $options); + } + + /** + * Snake_case alias for createResponse (matches the provided example). + * + * @param array $params + * @param array $options + * @return array + */ + public static function create_response(array $params, array $options = []): array + { + return self::createResponse($params, $options); + } + + /** + * Perform a raw request to the AI proxy. + * + * @param string $path Endpoint (may be an absolute URL). + * @param array $payload JSON payload. + * @param array $options Additional request options. + * @return array + */ + public static function request(?string $path = null, array $payload = [], array $options = []): array + { + if (!function_exists('curl_init')) { + return [ + 'success' => false, + 'error' => 'curl_missing', + 'message' => 'PHP cURL extension is missing. Install or enable it on the VM.', + ]; + } + + $cfg = self::config(); + + $projectUuid = $cfg['project_uuid']; + if (empty($projectUuid)) { + return [ + 'success' => false, + 'error' => 'project_uuid_missing', + 'message' => 'PROJECT_UUID is not defined; aborting AI request.', + ]; + } + + $defaultPath = $cfg['responses_path'] ?? null; + $resolvedPath = $path ?? ($options['path'] ?? $defaultPath); + if (empty($resolvedPath)) { + return [ + 'success' => false, + 'error' => 'project_id_missing', + 'message' => 'PROJECT_ID is not defined; cannot resolve AI proxy endpoint.', + ]; + } + + $url = self::buildUrl($resolvedPath, $cfg['base_url']); + $baseTimeout = isset($cfg['timeout']) ? (int) $cfg['timeout'] : 30; + $timeout = isset($options['timeout']) ? (int) $options['timeout'] : $baseTimeout; + if ($timeout <= 0) { + $timeout = 30; + } + + $baseVerifyTls = array_key_exists('verify_tls', $cfg) ? (bool) $cfg['verify_tls'] : true; + $verifyTls = array_key_exists('verify_tls', $options) + ? (bool) $options['verify_tls'] + : $baseVerifyTls; + + $projectHeader = $cfg['project_header']; + + $headers = [ + 'Content-Type: application/json', + 'Accept: application/json', + ]; + $headers[] = $projectHeader . ': ' . $projectUuid; + if (!empty($options['headers']) && is_array($options['headers'])) { + foreach ($options['headers'] as $header) { + if (is_string($header) && $header !== '') { + $headers[] = $header; + } + } + } + + if (!empty($projectUuid) && !array_key_exists('project_uuid', $payload)) { + $payload['project_uuid'] = $projectUuid; + } + + $body = json_encode($payload, JSON_UNESCAPED_UNICODE); + if ($body === false) { + return [ + 'success' => false, + 'error' => 'json_encode_failed', + 'message' => 'Failed to encode request body to JSON.', + ]; + } + + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifyTls); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verifyTls ? 2 : 0); + curl_setopt($ch, CURLOPT_FAILONERROR, false); + + $responseBody = curl_exec($ch); + if ($responseBody === false) { + $error = curl_error($ch) ?: 'Unknown cURL error'; + curl_close($ch); + return [ + 'success' => false, + 'error' => 'curl_error', + 'message' => $error, + ]; + } + + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $decoded = null; + if ($responseBody !== '' && $responseBody !== null) { + $decoded = json_decode($responseBody, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $decoded = null; + } + } + + if ($status >= 200 && $status < 300) { + return [ + 'success' => true, + 'status' => $status, + 'data' => $decoded ?? $responseBody, + ]; + } + + $errorMessage = 'AI proxy request failed'; + if (is_array($decoded)) { + $errorMessage = $decoded['error'] ?? $decoded['message'] ?? $errorMessage; + } elseif (is_string($responseBody) && $responseBody !== '') { + $errorMessage = $responseBody; + } + + return [ + 'success' => false, + 'status' => $status, + 'error' => $errorMessage, + 'response' => $decoded ?? $responseBody, + ]; + } + + /** + * Extract plain text from a Responses API payload. + * + * @param array $response Result of LocalAIApi::createResponse|request. + * @return string + */ + public static function extractText(array $response): string + { + $payload = $response['data'] ?? $response; + if (!is_array($payload)) { + return ''; + } + + if (!empty($payload['output']) && is_array($payload['output'])) { + $combined = ''; + foreach ($payload['output'] as $item) { + if (!isset($item['content']) || !is_array($item['content'])) { + continue; + } + foreach ($item['content'] as $block) { + if (is_array($block) && ($block['type'] ?? '') === 'output_text' && !empty($block['text'])) { + $combined .= $block['text']; + } + } + } + if ($combined !== '') { + return $combined; + } + } + + if (!empty($payload['choices'][0]['message']['content'])) { + return (string) $payload['choices'][0]['message']['content']; + } + + return ''; + } + + /** + * Attempt to decode JSON emitted by the model (handles markdown fences). + * + * @param array $response + * @return array|null + */ + public static function decodeJsonFromResponse(array $response): ?array + { + $text = self::extractText($response); + if ($text === '') { + return null; + } + + $decoded = json_decode($text, true); + if (is_array($decoded)) { + return $decoded; + } + + $stripped = preg_replace('/^```json|```$/m', '', trim($text)); + if ($stripped !== null && $stripped !== $text) { + $decoded = json_decode($stripped, true); + if (is_array($decoded)) { + return $decoded; + } + } + + return null; + } + + /** + * Load configuration from ai/config.php. + * + * @return array + */ + private static function config(): array + { + if (self::$configCache === null) { + $configPath = __DIR__ . '/config.php'; + if (!file_exists($configPath)) { + throw new RuntimeException('AI config file not found: ai/config.php'); + } + $cfg = require $configPath; + if (!is_array($cfg)) { + throw new RuntimeException('Invalid AI config format: expected array'); + } + self::$configCache = $cfg; + } + + return self::$configCache; + } + + /** + * Build an absolute URL from base_url and a path. + */ + private static function buildUrl(string $path, string $baseUrl): string + { + $trimmed = trim($path); + if ($trimmed === '') { + return $baseUrl; + } + if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) { + return $trimmed; + } + if ($trimmed[0] === '/') { + return $baseUrl . $trimmed; + } + return $baseUrl . '/' . $trimmed; + } +} + +// Legacy alias for backward compatibility with the previous class name. +if (!class_exists('OpenAIService')) { + class_alias(LocalAIApi::class, 'OpenAIService'); +} diff --git a/ai/config.php b/ai/config.php new file mode 100644 index 0000000..1ba1596 --- /dev/null +++ b/ai/config.php @@ -0,0 +1,52 @@ + $baseUrl, + 'responses_path' => $responsesPath, + 'project_id' => $projectId, + 'project_uuid' => $projectUuid, + 'project_header' => 'project-uuid', + 'default_model' => 'gpt-5', + 'timeout' => 30, + 'verify_tls' => true, +]; diff --git a/api/dummy_endpoint.php b/api/dummy_endpoint.php new file mode 100644 index 0000000..62ddda9 --- /dev/null +++ b/api/dummy_endpoint.php @@ -0,0 +1,80 @@ + 401, + 'statusMessage' => 'Unauthorized' + ]); + exit; +} + +// --- Request Method & Body --- +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode([ + 'statusCode' => 405, + 'statusMessage' => 'Method Not Allowed' + ]); + exit; +} + +$json_payload = file_get_contents('php://input'); +$data = json_decode($json_payload, true); + +// --- Validation --- +$required_fields = ['name', 'accountNumber', 'accountName', 'amount']; +$missing_fields = []; +foreach ($required_fields as $field) { + if (empty($data[$field])) { + $missing_fields[] = $field; + } +} + +if (!empty($missing_fields)) { + http_response_code(400); + echo json_encode([ + 'statusCode' => 400, + 'statusMessage' => 'Bad Request', + 'error' => 'Missing required fields: ' . implode(', ', $missing_fields) + ]); + exit; +} + +// --- Log Request (for debugging the dummy API itself) --- +// In a real app, you might not log to a file here, but it's useful for the dummy. +// The main app will log to its own DB. +$log_entry = [ + 'timestamp' => date('c'), + 'request' => $data, +]; + +// --- Process and Respond --- +// Simulate some random failures for realism +$should_fail = rand(1, 10) > 8; // 20% chance of failure + +if ($should_fail) { + http_response_code(500); + $response = [ + 'statusCode' => 500, + 'statusMessage' => 'Internal Server Error', + 'error' => 'A simulated random error occurred.' + ]; +} else { + http_response_code(200); + $response = [ + 'statusCode' => 200, + 'statusMessage' => 'sukses', + 'transactionId' => 'txn_' . uniqid() + ]; +} + +$log_entry['response'] = $response; +file_put_contents('dummy_api_log.txt', json_encode($log_entry) . PHP_EOL, FILE_APPEND); + +echo json_encode($response); \ No newline at end of file diff --git a/assets/css/custom.css b/assets/css/custom.css new file mode 100644 index 0000000..fa7950a --- /dev/null +++ b/assets/css/custom.css @@ -0,0 +1,23 @@ +body { + font-family: 'Inter', sans-serif; + background-color: #F8F9FA; +} + +.navbar-brand { + font-size: 1.5rem; +} + +.card { + border: none; + border-radius: 0.5rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.05); + transition: transform 0.2s ease-in-out; +} + +.card:hover { + transform: translateY(-5px); +} + +.table-hover tbody tr:hover { + background-color: rgba(0, 0, 0, 0.025); +} \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..830d46e --- /dev/null +++ b/assets/js/main.js @@ -0,0 +1,41 @@ +$(document).ready(function() { + const successToastEl = document.getElementById('successToast'); + const successToast = new bootstrap.Toast(successToastEl); + const previewModalEl = document.getElementById('previewModal'); + const previewModal = new bootstrap.Modal(previewModalEl); + + $('#uploadForm').on('submit', function(event) { + event.preventDefault(); + + const form = this; + const submitButton = $(form).find('button[type="submit"]'); + const spinner = submitButton.find('.spinner-border'); + + // Basic validation + if (form.checkValidity() === false) { + event.stopPropagation(); + $(form).addClass('was-validated'); + return; + } + + // Show loading state + submitButton.attr('disabled', true); + spinner.removeClass('d-none'); + + // Simulate async background process + setTimeout(function() { + // Show success toast + successToast.show(); + + // Show preview modal + previewModal.show(); + + // Reset form and button + form.reset(); + $(form).removeClass('was-validated'); + submitButton.attr('disabled', false); + spinner.addClass('d-none'); + + }, 1000); // Simulate a 1-second delay for effect + }); +}); \ No newline at end of file diff --git a/db/config.php b/db/config.php index f12ebaf..d054803 100644 --- a/db/config.php +++ b/db/config.php @@ -14,4 +14,4 @@ function db() { ]); } return $pdo; -} +} \ No newline at end of file diff --git a/db/setup.php b/db/setup.php new file mode 100644 index 0000000..d9cd593 --- /dev/null +++ b/db/setup.php @@ -0,0 +1,101 @@ + PDO::ERRMODE_EXCEPTION, + ]); + $pdo_admin->exec("CREATE DATABASE IF NOT EXISTS `".DB_NAME."`"); + + // Now connect to the app DB + $pdo = db(); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $statements = [ + // Users table for login + "CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role ENUM('maker', 'approver') NOT NULL, + createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );", + + // Payroll batches table + "CREATE TABLE IF NOT EXISTS payrolls ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + fileName VARCHAR(255) NOT NULL, + status ENUM('pending', 'approved', 'processing', 'delivered', 'failed') NOT NULL DEFAULT 'pending', + createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + createdBy INT, + approvedAt TIMESTAMP NULL, + approvedBy INT, + FOREIGN KEY (createdBy) REFERENCES users(id), + FOREIGN KEY (approvedBy) REFERENCES users(id) + );", + + // Payroll details (individual records from CSV) + "CREATE TABLE IF NOT EXISTS payroll_details ( + id INT AUTO_INCREMENT PRIMARY KEY, + payroll_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + accountNumber VARCHAR(50) NOT NULL, + accountName VARCHAR(255) NOT NULL, + amount DECIMAL(15, 2) NOT NULL, + status ENUM('pending', 'processing', 'delivered', 'failed') NOT NULL DEFAULT 'pending', + createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + createdBy INT, + approvedAt TIMESTAMP NULL, + approvedBy INT, + api_response TEXT, + FOREIGN KEY (payroll_id) REFERENCES payrolls(id) ON DELETE CASCADE, + FOREIGN KEY (createdBy) REFERENCES users(id), + FOREIGN KEY (approvedBy) REFERENCES users(id) + );", + + // API logs + "CREATE TABLE IF NOT EXISTS api_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + payroll_detail_id INT, + request_payload TEXT, + response_payload TEXT, + status_code INT, + createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (payroll_detail_id) REFERENCES payroll_details(id) ON DELETE SET NULL + );" + ]; + + foreach ($statements as $statement) { + $pdo->exec($statement); + } + + echo "Database and tables created or already exist successfully." . PHP_EOL; + + // --- User Seeding --- + // To ensure a clean state, we'll remove existing dummy users and recreate them. + // This makes the script safe to re-run. + $pdo->exec("DELETE FROM users WHERE username IN ('maker', 'approver')"); + + // Use prepared statements for security and reliability + $stmt = $pdo->prepare("INSERT INTO users (username, password, role) VALUES (:username, :password, :role)"); + + $users_to_seed = [ + ['username' => 'maker', 'password' => password_hash('password123', PASSWORD_DEFAULT), 'role' => 'maker'], + ['username' => 'approver', 'password' => password_hash('password123', PASSWORD_DEFAULT), 'role' => 'approver'] + ]; + + foreach ($users_to_seed as $user) { + $stmt->execute($user); + } + + echo "Dummy users 'maker' and 'approver' created/reset successfully with password 'password123'." . PHP_EOL; + + +} catch (PDOException $e) { + die("Database setup failed: " . $e->getMessage()); +} \ No newline at end of file diff --git a/index.php b/index.php index 7205f3d..4b6d6b3 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,170 @@ - + - - - New Style - - - - - - - - - - - - - - - - - - - + + + Payroll + + + + + + + + + + + + + + -
-
-

Analyzing your requirements and generating your website…

-
- Loading… -
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

+ + + +
+
+

Payroll Management

+
+ +
+
+
+
+
Upload Payroll
+

Upload a CSV file to start a new payroll batch.

+
+
+ + +
+
+ + +
+ +
+
+
+
+
+
+
+
Payroll Batches
+
+ + + + + + + + + + + + + + + + +
IDNameFile NameCreated AtStatusActions
No payroll batches found.
+
+
+
+
+
+
+ + + -
-
- Page updated: (UTC) -
+ + +
+ +
+ + + + diff --git a/login.php b/login.php new file mode 100644 index 0000000..621283f --- /dev/null +++ b/login.php @@ -0,0 +1,97 @@ +prepare('SELECT * FROM users WHERE username = :username'); + $stmt->execute(['username' => $username]); + $user = $stmt->fetch(); + + if ($user && password_verify($password, $user['password'])) { + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['role'] = $user['role']; + header('Location: index.php'); + exit; + } else { + $error_message = 'Invalid username or password.'; + } + } catch (PDOException $e) { + $error_message = 'Database error: ' . $e->getMessage(); + } + } +} +?> + + + + + + Login - Payroll App + + + + + + + + + + + + + \ No newline at end of file diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..e58edd6 --- /dev/null +++ b/logout.php @@ -0,0 +1,22 @@ +