diff --git a/app/Console/Commands/CheckMissingTranslations.php b/app/Console/Commands/CheckMissingTranslations.php new file mode 100644 index 0000000..01b9ed1 --- /dev/null +++ b/app/Console/Commands/CheckMissingTranslations.php @@ -0,0 +1,1382 @@ +`. */ + private const WILDCARD = "\x1A"; + + /** + * Recognised translation calls (the first argument is parsed separately): + * - __('x'), trans('x'), trans_choice('x') + * - @lang('x'), @choice('x') + * - Lang::get/choice/has/hasForLocale('x') + * - trans()->get('x'), app('translator')->get('x') + */ + private const CALL_PATTERN = <<<'REGEX' +~ +(?: + (?])(?:trans\(\)|app\(\s*['"]translator['"]\s*\))\s*->\s*(?:get|choice|has) + | (?:])(?:__|trans_choice|trans) + | @(?:lang|choice) + | (?])Lang::(?:get|choice|has|hasForLocale|getFromJson) +) +\s*\(\s*(?:key\s*:\s*)? +~x +REGEX; + + /** A single PHP string literal (single or double quoted, escapes supported, no line breaks). */ + private const LITERAL_PATTERN = '/(?|\'((?:[^\'\\\\\r\n]|\\\\.)*)\'|"((?:[^"\\\\\r\n]|\\\\.)*)")/'; + + /** Shape of a dot-notation key: group[/sub].key[.nested] */ + private const KEY_SHAPE = '/^[A-Za-z0-9_\-]+(?:\/[A-Za-z0-9_\-]+)*(?:\.[A-Za-z0-9_\-]+)+$/'; + + /** Directories (relative to base_path) that are scanned for key usage. */ + protected array $scanPaths = [ + 'app', + 'routes', + 'resources/views', + ]; + + /** Anchored to base_path(), so `vendor` only excludes the root /vendor directory. */ + protected array $defaultExclusions = [ + 'vendor', + 'storage', + 'bootstrap/cache', + 'node_modules', + 'resources/views/vendor', + '.git', + ]; + + /** Keys generated for every `generatePolicy('model')` call: permissions.{model}.{suffix}. */ + protected array $permissionKeySuffixes = [ + 'group-name', + 'viewAny', + 'view', + 'create', + 'update', + 'delete', + ]; + + /** Strings that look like keys but never are. */ + protected array $ignoredKeys = ['key']; + + /** Framework language files that are skipped by the unused check. */ + protected array $ignoredUnusedFiles = [ + 'validation', + 'auth', + 'pagination', + 'passwords', + ]; + + protected int $scannedFiles = 0; + + /** @var array> Static keys found in code, with their locations. */ + protected array $foundKeys = []; + + /** @var array> Dynamic key patterns (glob style) found in code. */ + protected array $foundPatterns = []; + + /** @var array Attribute / `new` handlers indexed by class name. */ + protected array $attributeHandlers = []; + + /** @var array Language data cache indexed by locale. */ + private array $langCache = []; + + public function __construct() + { + parent::__construct(); + + $this->registerAttributeHandler(new LayoutDataAttributeHandler()); + $this->registerAttributeHandler(new LayoutAttributeHandler()); + $this->registerAttributeHandler(new GenericSeoAttributeHandler('Seo')); + } + + public function registerAttributeHandler(AttributeScanHandler $handler): void + { + $this->attributeHandlers[$handler->attributeClass()] = $handler; + } + + // ------------------------------------------------------------------ + // Command flow + // ------------------------------------------------------------------ + + public function handle(): int + { + $purge = (bool) $this->option('purge'); + + if ($purge && $this->option('fix')) { + $this->error('--fix and --purge cannot be combined. Run them separately.'); + + return Command::FAILURE; + } + + if ($this->option('include-json') && ! $purge) { + $this->error('--include-json can only be used together with --purge.'); + + return Command::FAILURE; + } + + $primaryLocale = $this->argument('locale'); + $locales = $this->option('all-locales') ? $this->discoverLocales() : [$primaryLocale]; + + $this->info('Checking translation keys against locale(s): [' . implode(', ', $locales) . ']...'); + + $this->scanCodebase(); + + $this->line("Scanned {$this->scannedFiles} file(s), found " . count($this->foundKeys) . ' static key(s) and ' . count($this->foundPatterns) . ' dynamic pattern(s).'); + $this->newLine(); + + return ($this->option('unused') || $purge) + ? $this->checkUnusedKeys($primaryLocale) + : $this->checkMissingKeys($primaryLocale, $locales); + } + + /** + * Reports (and optionally fixes) keys used in code that are missing from the given locales. + */ + private function checkMissingKeys(string $primaryLocale, array $locales): int + { + $missingByLocale = []; + + foreach ($locales as $locale) { + $langData = $this->langData($locale); + + $missing = array_filter( + $this->foundKeys, + fn (array $occurrences, string $key) => ! $this->hasKey($langData, $key), + ARRAY_FILTER_USE_BOTH + ); + + if (! empty($missing)) { + $missingByLocale[$locale] = $missing; + } + } + + if (empty($missingByLocale)) { + $this->info('All translation keys are properly indexed across all checked locale(s)!'); + + return Command::SUCCESS; + } + + foreach ($missingByLocale as $locale => $missing) { + $this->printMissingTable($locale, $missing); + } + + if ($this->option('fix')) { + $status = Command::SUCCESS; + + foreach ($missingByLocale as $locale => $missing) { + if ($this->fixMissingKeys($locale, $missing) !== Command::SUCCESS) { + $status = Command::FAILURE; + } + } + + return $status; + } + + if ($this->option('ai')) { + $this->outputAiPrompt($primaryLocale, reset($missingByLocale)); + } else { + $this->comment('Tip: Run with --ai to generate a copyable prompt for AI to fix these keys:'); + $this->line(" php artisan lang:check {$primaryLocale} --all-locales --ai"); + $this->comment('Or add placeholders to the language files (preview first with --dry-run):'); + $this->line(" php artisan lang:check {$primaryLocale} --all-locales --fix --dry-run"); + } + + return Command::FAILURE; + } + + private function printMissingTable(string $locale, array $missing): void + { + $this->error('Found ' . count($missing) . " missing translation key(s) in locale [{$locale}]:"); + $this->newLine(); + + $rows = []; + foreach ($missing as $key => $occurrences) { + foreach ($occurrences as $occurrence) { + $rows[] = [$key, "{$occurrence['file']}:{$occurrence['line']}"]; + } + } + + $this->table(['Missing Key', 'Used In'], $rows); + $this->newLine(); + } + + /** + * Reports (and optionally purges) keys that are defined but never used. + */ + private function checkUnusedKeys(string $locale): int + { + $unusedKeys = $this->findUnusedKeys($this->langData($locale)); + + if (empty($unusedKeys)) { + $this->info('No unused translation keys found! All defined keys are actively used.'); + + return Command::SUCCESS; + } + + $this->warn('Found ' . count($unusedKeys) . ' unused translation key(s) in language files:'); + $this->newLine(); + + $this->table(['Unused Defined Key'], array_map(fn (string $key) => [$key], $unusedKeys)); + + if ($this->option('purge')) { + return $this->purgeUnusedKeys($locale); + } + + $this->newLine(); + $this->comment('Tip: remove them from the language files (preview first with --dry-run):'); + $this->line(" php artisan lang:check {$locale} --purge --dry-run"); + + return Command::FAILURE; + } + + // ------------------------------------------------------------------ + // Unused key detection + // ------------------------------------------------------------------ + + /** + * @return array Defined keys that nothing in the codebase uses. + */ + private function findUnusedKeys(array $langData): array + { + $used = []; + foreach (array_keys($this->foundKeys) as $key) { + $used[$this->normalizeKey($key)] = true; + } + + $patterns = array_unique(array_map( + fn (string $pattern) => $this->normalizeKey($pattern), + array_keys($this->foundPatterns) + )); + + return array_values(array_filter( + $this->flattenLangData($langData), + fn (string $key) => ! $this->isIgnoredUnusedKey($key) + && ! $this->isKeyCovered($this->normalizeKey($key), $used, $patterns) + )); + } + + /** + * A defined key counts as used when it is referenced directly, when one of its parents is + * referenced (e.g. __('group.parent') returns the whole array), or when it matches a dynamic pattern. + */ + private function isKeyCovered(string $key, array $used, array $patterns): bool + { + if (isset($used[$key])) { + return true; + } + + $segments = explode('.', $key); + for ($length = count($segments) - 1; $length > 0; $length--) { + if (isset($used[implode('.', array_slice($segments, 0, $length))])) { + return true; + } + } + + foreach ($patterns as $pattern) { + if (Str::is($pattern, $key)) { + return true; + } + } + + return false; + } + + /** + * Folder separators and dots are interchangeable when matching keys (`domains/x.key` equals `domains.x.key`). + */ + private function normalizeKey(string $key): string + { + return str_replace('/', '.', $key); + } + + private function isIgnoredUnusedKey(string $key): bool + { + foreach ($this->ignoredUnusedFiles as $ignored) { + if ( + str_starts_with($key, "{$ignored}.") + || str_contains($key, "/{$ignored}.") + || str_contains($key, ".{$ignored}.") + ) { + return true; + } + } + + return false; + } + + /** + * @return array Every defined key in dot notation (`group.nested.key` or the raw JSON key). + */ + private function flattenLangData(array $langData): array + { + $flattened = []; + + foreach ($langData as $prefix => $data) { + if ($prefix === self::JSON) { + array_push($flattened, ...array_map('strval', array_keys($data))); + } else { + array_push($flattened, ...$this->flattenArray($data, (string) $prefix)); + } + } + + return $flattened; + } + + /** + * Flattens an array into dot-notation keys. A list (0..n indexes) is kept as ONE key, + * because it is consumed as a whole by __('group.key'), which returns the array. + * + * @return array + */ + private function flattenArray(array $data, string $prefix): array + { + $keys = []; + + foreach ($data as $key => $value) { + $path = "{$prefix}.{$key}"; + + if (is_array($value) && $value !== [] && ! $this->isList($value)) { + array_push($keys, ...$this->flattenArray($value, $path)); + } else { + $keys[] = $path; + } + } + + return $keys; + } + + private function isList(array $array): bool + { + return array_keys($array) === array_keys(array_values($array)); + } + + // ------------------------------------------------------------------ + // Codebase scanning + // ------------------------------------------------------------------ + + private function scanCodebase(): void + { + $this->foundKeys = []; + $this->foundPatterns = []; + $this->scannedFiles = 0; + + $skips = array_filter((array) ($this->option('skip') ?? [])); + $selfPath = $this->slashes(__FILE__); + + foreach ($this->scanPaths as $path) { + if (! File::isDirectory(base_path($path))) { + continue; + } + + foreach (Finder::create()->files()->in(base_path($path))->name('*.php') as $file) { + $relativePath = $this->relativePath($file->getPathname()); + + if ($this->slashes($file->getPathname()) === $selfPath || $this->isExcluded($relativePath, $skips)) { + continue; + } + + $this->scannedFiles++; + $this->scanFile($file->getContents(), $relativePath); + } + } + } + + /** + * Default exclusions are anchored to the project root, while --skip values match at any depth. + */ + private function isExcluded(string $relativePath, array $skips): bool + { + foreach ($this->defaultExclusions as $excluded) { + $excluded = trim($this->slashes($excluded), '/'); + + if ($relativePath === $excluded || str_starts_with($relativePath, $excluded . '/')) { + return true; + } + } + + foreach ($skips as $skip) { + $skip = trim($this->slashes((string) $skip), '/'); + + if ($skip === '') { + continue; + } + + if (str_contains($skip, '*')) { + if (Str::is($skip, $relativePath) || Str::is('*/' . $skip, $relativePath)) { + return true; + } + + continue; + } + + if ( + $relativePath === $skip + || str_starts_with($relativePath, $skip . '/') + || str_contains($relativePath, '/' . $skip . '/') + || str_ends_with($relativePath, '/' . $skip) + ) { + return true; + } + } + + return false; + } + + private function scanFile(string $content, string $file): void + { + $content = $this->stripComments($content); + + $this->scanTranslationCalls($content, $file); + $this->scanAttributeHandlers($content, $file); + + if (str_contains($content, 'InteractsWithLabels') && str_contains($content, 'enum ')) { + $this->scanEnumKeys($content, $file); + } + + if (str_contains($content, 'function permissions(')) { + $this->scanPermissionKeys($content, $file); + } + } + + /** + * Stores a discovered key (or a dynamic pattern) together with its location. + */ + private function record(string $key, string $file, int $line, bool $dynamic = false): void + { + if (! $dynamic && $this->isInvalidKey($key)) { + return; + } + + $store = $dynamic ? 'foundPatterns' : 'foundKeys'; + $this->{$store}[$key]["{$file}:{$line}"] = ['file' => $file, 'line' => $line]; + } + + /** + * Finds keys in translation helper calls such as __('x'), trans('x') or @lang('x'). + */ + private function scanTranslationCalls(string $content, string $file): void + { + if (! preg_match_all(self::CALL_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE)) { + return; + } + + foreach ($matches[0] as [$text, $offset]) { + $line = $this->lineAt($content, $offset); + + foreach ($this->parseFirstArgument($content, $offset + strlen($text)) as $result) { + $this->record($result['key'], $file, $line, $result['dynamic']); + } + } + } + + /** + * Finds keys in `#[Name(...)]` attributes and `new Name(...)` instantiations by delegating + * the interpretation of the arguments to the registered handler of that class. + */ + private function scanAttributeHandlers(string $content, string $file): void + { + if (empty($this->attributeHandlers)) { + return; + } + + $names = implode('|', array_map('preg_quote', array_keys($this->attributeHandlers))); + + $patterns = [ + // #[Name(...)] attributes + '/#\[(' . $names . ')\s*\(((?:[^)]|\n)*?)\)\]/', + // new Name(...) / new \Fully\Qualified\Name(...), including multi-line bodies + '/new\s+\\\\?(?:[A-Za-z0-9_]+\\\\)*(' . $names . ')\s*\(((?:[^)]|\n)*?)\)/s', + ]; + + foreach ($patterns as $pattern) { + if (! preg_match_all($pattern, $content, $blocks, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) { + continue; + } + + foreach ($blocks as $block) { + $handler = $this->attributeHandlers[$block[1][0]] ?? null; + + if ($handler === null) { + continue; + } + + $line = $this->lineAt($content, $block[0][1]); + $arguments = $this->parseAttributeArguments($block[2][0]); + + foreach ($handler->extractTranslationKeys($arguments) as $key) { + $this->record($key, $file, $line); + } + } + } + } + + /** + * Turns the raw argument list of an attribute into an array for the handlers. + * Variables and function calls are replaced by placeholders and named arguments become array keys. + * + * NOTE: this relies on eval(), so it must only ever run on trusted source code. + */ + private function parseAttributeArguments(string $body): array + { + try { + $clean = preg_replace('/(?\w+|\[[^\]]*\])*/', "'{variable}'", $body); + $clean = preg_replace('/[A-Za-z_]\w*\([^)]*\)/', "'{call}'", $clean); + $evaluable = preg_replace('/([a-zA-Z0-9_]+)\s*:/', "'$1' =>", $clean); + + return eval("return [{$evaluable}];") ?? []; + } catch (\Throwable) { + return []; + } + } + + /** + * Parses the first argument of a translation call. + * + * Supports: + * - 'key' / "key" (including apostrophes and escapes, e.g. __("You're receiving this email")) + * - 'prefix.' . $var . '.suffix' => dynamic pattern "prefix.*.suffix" + * - "prefix.{$var}.suffix" => dynamic pattern "prefix.*.suffix" + * - $cond ? 'a.b' : 'a.c' => both literals are recorded as static keys + * - __('key') . ':' => not dynamic (the concatenation is outside the call) + * + * @return array + */ + private function parseFirstArgument(string $content, int $pos): array + { + $length = strlen($content); + $operands = []; + $buffer = ''; + $depth = 0; + + // Split the argument on top-level `.` (string concatenation) until the call ends. + for ($i = $pos; $i < $length; $i++) { + $char = $content[$i]; + + if ($char === "'" || $char === '"') { + $end = $this->skipString($content, $i); + $buffer .= substr($content, $i, $end - $i + 1); + $i = $end; + + continue; + } + + if ($char === '(' || $char === '[' || $char === '{') { + $depth++; + } elseif ($char === ')' || $char === ']' || $char === '}') { + if ($depth === 0) { + break; + } + $depth--; + } elseif ($depth === 0 && $char === ',') { + break; + } elseif ($depth === 0 && $char === '.') { + $prev = $content[$i - 1] ?? ''; + $next = $content[$i + 1] ?? ''; + $isSpread = $prev === '.' || $next === '.'; + $isDecimal = ctype_digit($prev) && ctype_digit($next); + + if (! $isSpread && ! $isDecimal) { + $operands[] = trim($buffer); + $buffer = ''; + + continue; + } + } + + $buffer .= $char; + } + + $operands[] = trim($buffer); + + if ($operands[0] === '') { + return []; // trans() / __() without arguments + } + + $key = implode('', array_map(fn (string $operand) => $this->decodeOperand($operand), $operands)); + $key = preg_replace('/' . self::WILDCARD . '+/', self::WILDCARD, $key); + + // Fully dynamic, e.g. __($key): only pick up key-shaped literals (ternary / match branches). + if ($key === self::WILDCARD) { + $expression = implode(' ', $operands); + // Strip helper calls whose arguments are never translation keys. + $expression = preg_replace('/(?:config|route|env|view|session|old)\s*\((?:[^()]+|\([^()]*\))*\)/i', '', $expression); + + $results = []; + foreach ($this->extractLiterals($expression) as [$literal]) { + if (preg_match(self::KEY_SHAPE, $literal) && ! $this->isInvalidKey($literal)) { + $results[] = ['key' => $literal, 'dynamic' => false]; + } + } + + return $results; + } + + $dynamic = str_contains($key, self::WILDCARD); + + return [['key' => $dynamic ? str_replace(self::WILDCARD, '*', $key) : $key, 'dynamic' => $dynamic]]; + } + + /** + * Returns the literal value of an operand, with the WILDCARD marker for anything dynamic. + */ + private function decodeOperand(string $operand): string + { + if (preg_match('/^\'((?:[^\'\\\\]|\\\\.)*)\'$/s', $operand, $m)) { + return str_replace(["\\'", '\\\\'], ["'", '\\'], $m[1]); + } + + if (preg_match('/^"((?:[^"\\\\]|\\\\.)*)"$/s', $operand, $m)) { + $inner = preg_replace( + '/\{\$(?:[^{}]|\{[^{}]*\})*\}|(?\w+|\[[^\]]*\])*/', + self::WILDCARD, + $m[1] + ); + + return stripcslashes($inner); + } + + return self::WILDCARD; + } + + /** + * Index of the closing quote of the string literal that starts at $i (or the last index if unterminated). + */ + private function skipString(string $text, int $i): int + { + $quote = $text[$i]; + $length = strlen($text); + + for ($i++; $i < $length && $text[$i] !== $quote; $i++) { + if ($text[$i] === '\\') { + $i++; + } + } + + return min($i, $length - 1); + } + + /** + * @return array Every string literal as [content, offset]. + */ + private function extractLiterals(string $content): array + { + return preg_match_all(self::LITERAL_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE) ? $matches[1] : []; + } + + /** + * Removes Blade comments, block comments that start a line and whole-line `//` comments. + * Line breaks are preserved so reported line numbers stay accurate. + */ + private function stripComments(string $content): string + { + $blank = static fn (array $m): string => str_repeat("\n", substr_count($m[0], "\n")); + + $content = preg_replace_callback('/\{\{--.*?--\}\}/s', $blank, $content) ?? $content; + $content = preg_replace_callback('~^[ \t]*/\*.*?\*/~ms', $blank, $content) ?? $content; + + return preg_replace('~^[ \t]*//.*$~m', '', $content) ?? $content; + } + + private function lineAt(string $content, int $offset): int + { + return substr_count($content, "\n", 0, $offset) + 1; + } + + /** + * Runs a pattern against every line. + * + * @return array [regex matches, 1-based line number] for each matching line. + */ + private function matchLines(string $content, string $pattern): array + { + $results = []; + + foreach (explode("\n", $content) as $index => $line) { + if (preg_match($pattern, $line, $matches)) { + $results[] = [$matches, $index + 1]; + } + } + + return $results; + } + + /** + * Keys declared by `'description' => '...'` entries and by `generatePolicy('model')` calls. + */ + private function scanPermissionKeys(string $content, string $file): void + { + foreach ($this->matchLines($content, '/[\'"]description[\'"]\s*=>\s*[\'"]([^\'"]+)[\'"]/') as [$match, $line]) { + $this->record($match[1], $file, $line); + } + + if (! preg_match_all('/generatePolicy\(\s*[\'"]([^\'"]+)[\'"]/', $content, $matches, PREG_OFFSET_CAPTURE)) { + return; + } + + foreach ($matches[1] as [$model, $offset]) { + $line = $this->lineAt($content, $offset); + + foreach ($this->permissionKeySuffixes as $suffix) { + $this->record("permissions.{$model}.{$suffix}", $file, $line); + } + + // Wildcard so every permissions.{model}.* key counts as used by the unused check. + $this->record("permissions.{$model}.*", $file, $line, true); + } + } + + /** + * Keys derived from the cases of a backed enum that uses the InteractsWithLabels trait. + * The prefix follows the enum namespace: domains/{domain}/enum.{slug}, ui/enum.{slug} or enum.{slug}. + */ + private function scanEnumKeys(string $content, string $file): void + { + if ( + ! preg_match('/namespace\s+([^;]+);/', $content, $namespace) + || ! preg_match('/enum\s+([^\s:]+)/', $content, $enum) + ) { + return; + } + + $fqcn = trim($namespace[1]) . '\\' . trim($enum[1]); + $slug = Str::snake(trim($enum[1])); + + if (Str::startsWith($fqcn, 'App\\Domains\\')) { + $prefix = 'domains/' . Str::kebab(Str::between($fqcn, 'App\\Domains\\', '\\Enums\\')) . "/enum.{$slug}"; + } elseif (Str::startsWith($fqcn, 'App\\UI\\Enums\\')) { + $prefix = "ui/enum.{$slug}"; + } else { + $prefix = "enum.{$slug}"; + } + + foreach ($this->matchLines($content, '/case\s+[A-Za-z0-9_]+\s*=\s*[\'"]([^\'"]+)[\'"]\s*;/') as [$match, $line]) { + $this->record("{$prefix}.{$match[1]}", $file, $line); + } + } + + /** + * Strings that can never be translation keys: placeholders, variables, spread / namespace artefacts. + */ + private function isInvalidKey(string $key): bool + { + return $key === '' + || str_contains($key, '::') + || str_contains($key, '{$') + || preg_match('/^\$\w+$/', $key) === 1 + || preg_match('/\{[A-Za-z0-9_\.]+\}/', $key) === 1 + || preg_match('/^\{[A-Za-z0-9_]+\}$/', $key) === 1 + || str_contains($key, '...') + || str_contains($key, ')]') + || in_array($key, $this->ignoredKeys, true); + } + + // ------------------------------------------------------------------ + // Language data + // ------------------------------------------------------------------ + + /** + * Cached language data of a locale (see loadLanguageData()). + */ + private function langData(string $locale): array + { + return $this->langCache[$locale] ??= $this->loadLanguageData($locale); + } + + /** + * Loads `lang/{locale}.json` into the JSON bucket and every `lang/{locale}/**.php` file + * into a bucket named after its relative path without extension (e.g. `domains/billing/enum`). + */ + private function loadLanguageData(string $locale): array + { + $langData = []; + $jsonPath = lang_path("{$locale}.json"); + + if (File::exists($jsonPath)) { + $langData[self::JSON] = json_decode(File::get($jsonPath), true) ?? []; + } + + if (File::isDirectory(lang_path($locale))) { + foreach (File::allFiles(lang_path($locale)) as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $content = require $file->getPathname(); + + if (is_array($content)) { + $langData[substr($this->slashes($file->getRelativePathname()), 0, -4)] = $content; + } + } + } + + return $langData; + } + + /** + * Whether a key is defined: either a flat JSON key or `bucket.nested.key` inside a PHP file. + * Buckets are matched both literally and with `/` treated as `.`. + */ + private function hasKey(array $langData, string $key): bool + { + if (isset($langData[self::JSON]) && array_key_exists($key, $langData[self::JSON])) { + return true; + } + + if (! str_contains($key, '.')) { + return false; + } + + foreach ($langData as $prefix => $data) { + if ($prefix === self::JSON) { + continue; + } + + foreach ([[(string) $prefix, $key], [$this->normalizeKey((string) $prefix), $this->normalizeKey($key)]] as [$bucket, $candidate]) { + if (str_starts_with($candidate, $bucket . '.') && Arr::has($data, substr($candidate, strlen($bucket) + 1))) { + return true; + } + } + } + + return false; + } + + /** + * @return array Every locale that has a directory or a JSON file in the lang path. + */ + private function discoverLocales(): array + { + $locales = []; + + foreach (File::directories(lang_path()) as $dir) { + if (basename($dir) !== 'vendor') { + $locales[] = basename($dir); + } + } + + foreach (File::glob(lang_path('*.json')) ?: [] as $jsonFile) { + $locales[] = basename($jsonFile, '.json'); + } + + return array_values(array_unique($locales)); + } + + // ------------------------------------------------------------------ + // Fix (add missing keys) + // ------------------------------------------------------------------ + + /** + * Adds placeholder values for the missing keys of one locale. + */ + private function fixMissingKeys(string $locale, array $missingKeys): int + { + ksort($missingKeys); + + $mark = (string) ($this->option('mark') ?? ''); + $mark = $mark === '' ? '' : rtrim($mark) . ' '; + + $data = $this->langData($locale); + $added = []; + $conflicts = []; + + foreach (array_keys($missingKeys) as $key) { + [$bucket, $item] = $this->resolveTarget($key); + $value = $mark . ($bucket === self::JSON ? $key : $this->humanize($item)); + + if ($bucket === self::JSON) { + $data[self::JSON][$key] = $value; + } else { + $data[$bucket] ??= []; + + if (! $this->setPath($data[$bucket], $item, $value)) { + $conflicts[] = $key; + + continue; + } + } + + $added[$key] = ['bucket' => $bucket, 'value' => $value]; + } + + [$writable, $skipped] = $this->splitRewritable($locale, array_unique(array_column($added, 'bucket'))); + + $rows = []; + foreach ($added as $key => $info) { + if (in_array($info['bucket'], $writable, true)) { + $rows[] = [$key, $info['value'], $this->langFileLabel($locale, $info['bucket'])]; + } + } + + $this->newLine(); + + if (! empty($rows)) { + $this->info('The following keys will be added:'); + $this->table(['Key', 'Placeholder Value', 'File'], $rows); + } + + foreach ($conflicts as $key) { + $this->error("Cannot add [{$key}]: a parent segment already holds a plain string value. Resolve it manually."); + } + + $this->warnSkipped($skipped); + + $question = 'Write ' . count($rows) . ' key(s) into ' . count($writable) . ' file(s)? Comments and custom formatting in those files will be lost.'; + + if (empty($writable) || ! $this->shouldWrite($question)) { + return Command::FAILURE; + } + + $this->writeBuckets($locale, $data, $writable); + + $this->info('Added ' . count($rows) . ' placeholder key(s) to ' . count($writable) . ' file(s). Replace the placeholder values with real translations.'); + + return (empty($conflicts) && empty($skipped)) ? Command::SUCCESS : Command::FAILURE; + } + + /** + * Dot-notation keys (`group.item`) go to the PHP file of their group, anything else + * (sentences, spaces, trailing dots, ...) goes to the JSON file. + * + * @return array{0: string, 1: string} [bucket, item] + */ + private function resolveTarget(string $key): array + { + return preg_match(self::KEY_SHAPE, $key) ? explode('.', $key, 2) : [self::JSON, $key]; + } + + /** + * Turns the last key segment into a readable placeholder, e.g. `viewAny` becomes "View any". + */ + private function humanize(string $item): string + { + $segments = explode('.', $item); + $words = preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', (string) end($segments)); + + return ucfirst(mb_strtolower(trim(str_replace(['-', '_'], ' ', $words)))); + } + + /** + * Sets a dot-notation path. Returns false when the target exists or a parent holds a + * non-array value, because writing would overwrite existing data. + */ + private function setPath(array &$data, string $path, string $value): bool + { + $segments = explode('.', $path); + $last = count($segments) - 1; + $ref = &$data; + + foreach ($segments as $index => $segment) { + if ($index === $last) { + if (array_key_exists($segment, $ref)) { + return false; + } + + $ref[$segment] = $value; + + return true; + } + + $ref[$segment] ??= []; + + if (! is_array($ref[$segment])) { + return false; + } + + $ref = &$ref[$segment]; + } + + return false; + } + + // ------------------------------------------------------------------ + // Purge (remove unused keys) + // ------------------------------------------------------------------ + + /** + * Removes the unused keys of the given locale (or of every locale with --all-locales). + * Unused keys are recalculated per locale because each one may define different keys. + */ + private function purgeUnusedKeys(string $primaryLocale): int + { + $includeJson = (bool) $this->option('include-json'); + $locales = $this->option('all-locales') ? $this->discoverLocales() : [$primaryLocale]; + + $plans = []; + $skippedJson = 0; + $skippedFiles = []; + + foreach ($locales as $locale) { + $data = $this->langData($locale); + $removed = []; + + foreach ($this->findUnusedKeys($data) as $key) { + $bucket = $this->locateKey($data, $key); + + if ($bucket === null) { + continue; + } + + if ($bucket === self::JSON) { + if (! $includeJson) { + $skippedJson++; + + continue; + } + + unset($data[self::JSON][$key]); + } else { + $this->forgetPath($data[$bucket], substr($key, strlen($bucket) + 1)); + } + + $removed[$bucket][] = $key; + } + + [$writable, $skipped] = $this->splitRewritable($locale, array_keys($removed)); + array_push($skippedFiles, ...$skipped); + + if (! empty($writable)) { + $plans[$locale] = ['data' => $data, 'removed' => array_intersect_key($removed, array_flip($writable))]; + } + } + + $this->newLine(); + $this->warnSkipped($skippedFiles); + + if ($skippedJson > 0) { + $this->comment("{$skippedJson} unused JSON key(s) were left untouched. Use --include-json to remove them as well."); + } + + if (empty($plans)) { + $this->warn('Nothing can be purged automatically.'); + + return Command::FAILURE; + } + + $rows = []; + $total = 0; + foreach ($plans as $locale => $plan) { + foreach ($plan['removed'] as $bucket => $keys) { + $rows[] = [$locale, $this->langFileLabel($locale, $bucket), count($keys)]; + $total += count($keys); + } + } + + $this->info('The following keys will be removed:'); + $this->table(['Locale', 'File', 'Keys to Remove'], $rows); + + if (! $this->shouldWrite("Remove {$total} key(s) from " . count($rows) . ' file(s)? Comments and custom formatting in those files will be lost.')) { + return Command::FAILURE; + } + + foreach ($plans as $locale => $plan) { + $this->writeBuckets($locale, $plan['data'], array_keys($plan['removed'])); + } + + $this->info("Removed {$total} unused key(s) from " . count($rows) . ' file(s).'); + + return empty($skippedFiles) ? Command::SUCCESS : Command::FAILURE; + } + + /** + * Finds the bucket (JSON or PHP file) that defines a flattened key. + */ + private function locateKey(array $data, string $key): ?string + { + if (isset($data[self::JSON]) && array_key_exists($key, $data[self::JSON])) { + return self::JSON; + } + + foreach (array_keys($data) as $bucket) { + if ($bucket !== self::JSON && str_starts_with($key, $bucket . '.')) { + return $bucket; + } + } + + return null; + } + + /** + * Removes a dot-notation path and prunes parent arrays that become empty. + */ + private function forgetPath(array &$data, string $path): void + { + if (array_key_exists($path, $data)) { + unset($data[$path]); + + return; + } + + if (! str_contains($path, '.')) { + return; + } + + [$head, $tail] = explode('.', $path, 2); + + if (! is_array($data[$head] ?? null)) { + return; + } + + $this->forgetPath($data[$head], $tail); + + if ($data[$head] === []) { + unset($data[$head]); + } + } + + // ------------------------------------------------------------------ + // Writing language files (shared by --fix and --purge) + // ------------------------------------------------------------------ + + /** + * Splits buckets into files that can be rewritten and PHP files that must be left alone. + * + * @return array{0: array, 1: array} [writable buckets, skipped file paths] + */ + private function splitRewritable(string $locale, array $buckets): array + { + $writable = []; + $skipped = []; + + foreach ($buckets as $bucket) { + $path = lang_path("{$locale}/{$bucket}.php"); + + if ($bucket !== self::JSON && ! $this->isSafelyRewritable($path)) { + $skipped[] = $path; + } else { + $writable[] = $bucket; + } + } + + return [$writable, $skipped]; + } + + private function warnSkipped(array $paths): void + { + foreach ($paths as $path) { + $this->warn('Skipped ' . $this->relativePath($path) . ': it contains executable PHP (closures, constants, function calls) and cannot be rewritten safely. Edit it manually.'); + } + } + + /** + * Applies --dry-run / --force / interactive confirmation. Returns true when files may be written. + */ + private function shouldWrite(string $question): bool + { + if ($this->option('dry-run')) { + $this->comment('Dry run: no files were written.'); + + return false; + } + + if (! $this->option('force') && ! $this->confirm($question, false)) { + $this->comment('Aborted. No files were written.'); + + return false; + } + + return true; + } + + private function writeBuckets(string $locale, array $data, array $buckets): void + { + foreach ($buckets as $bucket) { + $this->writeLangFile($locale, $bucket, $data[$bucket] ?? []); + } + } + + private function writeLangFile(string $locale, string $bucket, array $data): void + { + if ($bucket === self::JSON) { + $json = json_encode( + $data === [] ? new \stdClass : $data, + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_FORCE_OBJECT + ); + + File::put(lang_path("{$locale}.json"), $json . "\n"); + + return; + } + + $path = lang_path("{$locale}/{$bucket}.php"); + File::ensureDirectoryExists(dirname($path)); + File::put($path, "exportPhpArray($data) . ";\n"); + } + + /** + * Exports an array as short-syntax PHP with 4-space indentation. + */ + private function exportPhpArray(array $data, int $depth = 0): string + { + if ($data === []) { + return '[]'; + } + + $indent = str_repeat(' ', $depth + 1); + $isList = $this->isList($data); + $lines = []; + + foreach ($data as $key => $value) { + $keyPart = $isList ? '' : $this->exportScalar($key) . ' => '; + $valuePart = is_array($value) ? $this->exportPhpArray($value, $depth + 1) : $this->exportScalar($value); + + $lines[] = $indent . $keyPart . $valuePart . ','; + } + + return "[\n" . implode("\n", $lines) . "\n" . str_repeat(' ', $depth) . ']'; + } + + private function exportScalar(mixed $value): string + { + return match (true) { + is_string($value) => "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $value) . "'", + is_bool($value) => $value ? 'true' : 'false', + $value === null => 'null', + default => var_export($value, true), + }; + } + + /** + * A language file can only be re-exported safely when it contains pure literals + * (arrays, strings, numbers, true/false/null). Closures, constants, concatenation or + * function calls would be lost or changed by an export. + */ + private function isSafelyRewritable(string $path): bool + { + if (! File::exists($path)) { + return true; + } + + $allowedTokens = [ + T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT, T_RETURN, T_ARRAY, + T_DOUBLE_ARROW, T_CONSTANT_ENCAPSED_STRING, T_LNUMBER, T_DNUMBER, T_DECLARE, + ]; + + foreach (token_get_all(File::get($path)) as $token) { + if (is_string($token)) { + if (! str_contains('[],();-+=', $token)) { + return false; + } + + continue; + } + + [$id, $text] = $token; + + if (in_array($id, $allowedTokens, true)) { + continue; + } + + if ($id === T_STRING && in_array(strtolower($text), ['true', 'false', 'null', 'strict_types'], true)) { + continue; + } + + return false; + } + + return true; + } + + // ------------------------------------------------------------------ + // Paths and output helpers + // ------------------------------------------------------------------ + + private function slashes(string $path): string + { + return str_replace('\\', '/', $path); + } + + private function relativePath(string $path): string + { + return ltrim(str_replace(rtrim($this->slashes(base_path()), '/'), '', $this->slashes($path)), '/'); + } + + private function langFileLabel(string $locale, string $bucket): string + { + return $this->relativePath($bucket === self::JSON ? lang_path("{$locale}.json") : lang_path("{$locale}/{$bucket}.php")); + } + + /** + * Prints a ready-to-copy prompt that asks an AI assistant to write the missing translations. + */ + private function outputAiPrompt(string $locale, array $missingKeys): void + { + $this->newLine(); + $this->info('==================== AI TRANSLATION PROMPT ===================='); + $this->comment('Copy the block below and paste it into ChatGPT, Claude, Gemini, or Cursor:'); + $this->newLine(); + + // Group by the first key segment, which is also the language file the key belongs to. + $groupedByDomain = []; + foreach ($missingKeys as $key => $occurrences) { + $parts = explode('.', $key); + $domain = count($parts) > 1 ? array_shift($parts) : 'general'; + $groupedByDomain[$domain][$key] = array_column($occurrences, 'file'); + } + + $prompt = "I have missing translation keys in my Laravel project for locale [{$locale}].\n"; + $prompt .= "Please generate the corresponding PHP array files or JSON entries for these keys.\n\n"; + $prompt .= "### Expected Structure:\n"; + $prompt .= "- For domain-prefixed keys (e.g. `domains/identity.field.user.name`), create or update `lang/{$locale}/domains/identity.php`.\n"; + $prompt .= "- Keys without a dot (e.g. `You're receiving this email`) belong in `lang/{$locale}.json`.\n"; + $prompt .= "- Use proper nested PHP arrays representing dot-notation keys.\n"; + $prompt .= "- Translate values naturally for target locale [{$locale}].\n\n"; + $prompt .= "### Missing Keys to Index:\n\n"; + + foreach ($groupedByDomain as $domain => $keys) { + $prompt .= "#### Domain/File: {$domain}\n"; + + foreach ($keys as $key => $files) { + $prompt .= "- `{$key}` (Used in: " . implode(', ', array_slice(array_unique($files), 0, 2)) . ")\n"; + } + + $prompt .= "\n"; + } + + $prompt .= 'Please output the ready-to-use PHP array code for each file.'; + + $this->line($prompt); + $this->newLine(); + $this->info('==============================================================='); + } +} diff --git a/app/Console/Commands/Contracts/AttributeScanHandler.php b/app/Console/Commands/Contracts/AttributeScanHandler.php new file mode 100644 index 0000000..bd6403d --- /dev/null +++ b/app/Console/Commands/Contracts/AttributeScanHandler.php @@ -0,0 +1,19 @@ + + */ + public function extractTranslationKeys(array $arguments): array; +} diff --git a/app/Console/Commands/DomainMakeCommand.php b/app/Console/Commands/DomainMakeCommand.php index 109633b..27d3820 100644 --- a/app/Console/Commands/DomainMakeCommand.php +++ b/app/Console/Commands/DomainMakeCommand.php @@ -9,7 +9,7 @@ use Illuminate\Support\Str; class DomainMakeCommand extends Command { protected $signature = 'domain:make - {type : Type to generate: model, action, dto, enum, event, listener, notification, policy, query, provider, relationship-provider, export, mapper, scope, mailable} + {type : Type to generate: model, action, dto, enum, event, listener, notification, policy, query, provider, relationship-provider, export, mapper, scope, mailable, input-schema} {domain : Domain name, e.g. Identity, Account, System} {name : Class name, supports sub-paths e.g. Backup/DeleteBackup} {--factory : Also generate a factory (model only)} @@ -40,6 +40,7 @@ class DomainMakeCommand extends Command 'integration' => 'Integration', 'mapper' => 'Integration/Mappers', 'mailable' => 'Mail', + 'input-schema' => 'Support/Schemas', ]; public function __construct(protected Filesystem $files) diff --git a/app/Console/Commands/Handlers/GenericSeoAttributeHandler.php b/app/Console/Commands/Handlers/GenericSeoAttributeHandler.php new file mode 100644 index 0000000..5445976 --- /dev/null +++ b/app/Console/Commands/Handlers/GenericSeoAttributeHandler.php @@ -0,0 +1,28 @@ +className; + } + + public function extractTranslationKeys(array $arguments): array + { + $keys = []; + + array_walk_recursive($arguments, function ($val) use (&$keys) { + if (is_string($val) && (str_contains($val, '/') || str_contains($val, '.'))) { + $keys[] = $val; + } + }); + + return $keys; + } +} diff --git a/app/Console/Commands/Handlers/LayoutAttributeHandler.php b/app/Console/Commands/Handlers/LayoutAttributeHandler.php new file mode 100644 index 0000000..e148492 --- /dev/null +++ b/app/Console/Commands/Handlers/LayoutAttributeHandler.php @@ -0,0 +1,33 @@ +isTranslationKey($arguments['header'])) { + $keys[] = $arguments['header']; + } + + // 2. Ambil breadcrumbs (hanya KEY/LABEL dari array, abaikan URL/Value) + if (isset($arguments['breadcrumbs']) && is_array($arguments['breadcrumbs'])) { + foreach ($arguments['breadcrumbs'] as $labelKey => $url) { + if (is_string($labelKey) && $this->isTranslationKey($labelKey)) { + $keys[] = $labelKey; + } + } + } + + return $keys; + } + + private function isTranslationKey(string $value): bool + { + return str_contains($value, '/') || str_contains($value, '.'); + } +} diff --git a/app/Console/stubs/domain-make/enum.stub b/app/Console/stubs/domain-make/enum.stub index 5ae4f33..c99be57 100644 --- a/app/Console/stubs/domain-make/enum.stub +++ b/app/Console/stubs/domain-make/enum.stub @@ -2,7 +2,13 @@ namespace {{ namespace }}; -enum {{ class }}: string + use App\Domains\System\Traits\Enum\HasPredicateMethod; + use App\UI\Enums\Concerns\InteractsWithLabels; +use App\UI\Enums\Contracts\HasLabel; + +enum {{ class }}: string implements HasLabel { - // -} \ No newline at end of file + use HasPredicateMethod; + use InteractsWithLabels; + +} diff --git a/app/Console/stubs/domain-make/input-schema.stub b/app/Console/stubs/domain-make/input-schema.stub new file mode 100644 index 0000000..6a8cfa6 --- /dev/null +++ b/app/Console/stubs/domain-make/input-schema.stub @@ -0,0 +1,20 @@ +addInput( + InputSchema::make() + ); + } +} diff --git a/app/Console/stubs/domain-make/query.stub b/app/Console/stubs/domain-make/query.stub index c9f4af9..448695f 100644 --- a/app/Console/stubs/domain-make/query.stub +++ b/app/Console/stubs/domain-make/query.stub @@ -2,12 +2,12 @@ namespace {{ namespace }}; -use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Collection; class {{ class }} { - public function fetch(): Collection + public static function fetch(): Collection { return new Collection(); } -} \ No newline at end of file +} diff --git a/app/Domains/Academic/Queries/Lookup/FacultyLookup.php b/app/Domains/Academic/Queries/Lookup/FacultyLookup.php index 92a87c3..426cad6 100644 --- a/app/Domains/Academic/Queries/Lookup/FacultyLookup.php +++ b/app/Domains/Academic/Queries/Lookup/FacultyLookup.php @@ -10,9 +10,9 @@ class FacultyLookup public static function fetch(): Collection { return once(fn () => Faculty::query() - ->select(['id', 'name']) + ->select(['ulid', 'name']) ->latest() ->get() - ->pluck('name', 'id')); + ->pluck('name', 'ulid')); } } diff --git a/app/Domains/Academic/Queries/Lookup/StudyProgramLookup.php b/app/Domains/Academic/Queries/Lookup/StudyProgramLookup.php index a670acb..26e85eb 100644 --- a/app/Domains/Academic/Queries/Lookup/StudyProgramLookup.php +++ b/app/Domains/Academic/Queries/Lookup/StudyProgramLookup.php @@ -14,10 +14,10 @@ class StudyProgramLookup } return once(fn () => StudyProgram::query() - ->select('id', 'name') - ->where('faculty_id', $context['faculty']) + ->select('ulid', 'name') + ->whereRelationUlid('faculty', $context['faculty']) ->latest() ->get() - ->pluck('name', 'id')); + ->pluck('name', 'ulid')); } } diff --git a/app/Domains/Academic/Queries/Lookup/TermLookup.php b/app/Domains/Academic/Queries/Lookup/TermLookup.php index a67f820..4a3177f 100644 --- a/app/Domains/Academic/Queries/Lookup/TermLookup.php +++ b/app/Domains/Academic/Queries/Lookup/TermLookup.php @@ -10,7 +10,7 @@ class TermLookup public static function fetch($search = null): Collection { return once(fn () => Term::query() - ->select(['id', 'name']) + ->select(['name']) ->when($search, fn ($query) => $query->where('name', 'like', "%{$search}%")) ->latest('date_start') ->pluck('name', 'name')); diff --git a/app/Domains/Accounting/Providers/RelationshipServiceProvider.php b/app/Domains/Accounting/Providers/RelationshipServiceProvider.php index cda4592..c0c0f78 100644 --- a/app/Domains/Accounting/Providers/RelationshipServiceProvider.php +++ b/app/Domains/Accounting/Providers/RelationshipServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Domains\Accounting\Providers; +use App\Domains\Accounting\Models\ChartOfAccount; +use App\Domains\Billing\Models\Product; use Illuminate\Support\ServiceProvider; class RelationshipServiceProvider extends ServiceProvider @@ -26,6 +28,11 @@ class RelationshipServiceProvider extends ServiceProvider */ public function boot(): void { - // Register cross-domain relationships below. + ChartOfAccount::resolveRelationUsing('product', fn (ChartOfAccount $coa) => + $coa->hasMany(Product::class, 'coa_id') + ); + Product::resolveRelationUsing('chartOfAccount', fn (Product $product) => + $product->belongsTo(ChartOfAccount::class, 'chart_of_account_id') + ); } } diff --git a/app/Domains/Admission/Integration/Mappers/FeeRateDataMapper.php b/app/Domains/Admission/Integration/Mappers/FeeRateDataMapper.php index cde91c4..628e649 100644 --- a/app/Domains/Admission/Integration/Mappers/FeeRateDataMapper.php +++ b/app/Domains/Admission/Integration/Mappers/FeeRateDataMapper.php @@ -34,6 +34,7 @@ class FeeRateDataMapper implements DataPayloadMapper if(!isset($rawData['jalur'])){ dd($rawData); } + // Normalize incoming data array formats into an internal domain-safe layout return [ 'admission_schedule_id' => $this->resolveAdmissionScheduleId($rawData['jalur'], $rawData['angkatan']), diff --git a/app/Domains/Admission/Queries/Lookup/AcademicProgramLookup.php b/app/Domains/Admission/Queries/Lookup/AcademicProgramLookup.php new file mode 100644 index 0000000..a74e955 --- /dev/null +++ b/app/Domains/Admission/Queries/Lookup/AcademicProgramLookup.php @@ -0,0 +1,18 @@ + AcademicProgram::query() + ->select(['ulid', 'name']) + ->latest() + ->get() + ->pluck('name', 'ulid')); + } +} diff --git a/app/Domains/Admission/Queries/Lookup/AdmissionTrackLookup.php b/app/Domains/Admission/Queries/Lookup/AdmissionTrackLookup.php new file mode 100644 index 0000000..29ac0c7 --- /dev/null +++ b/app/Domains/Admission/Queries/Lookup/AdmissionTrackLookup.php @@ -0,0 +1,18 @@ + AdmissionTrack::query() + ->select(['ulid', 'name']) + ->latest() + ->get() + ->pluck('name', 'ulid')); + } +} diff --git a/app/Domains/Admission/Queries/Lookup/FeeTypeLookup.php b/app/Domains/Admission/Queries/Lookup/FeeTypeLookup.php index f79e0fb..2ad0e57 100644 --- a/app/Domains/Admission/Queries/Lookup/FeeTypeLookup.php +++ b/app/Domains/Admission/Queries/Lookup/FeeTypeLookup.php @@ -10,9 +10,9 @@ class FeeTypeLookup public static function fetch(): Collection { return once(fn () => FeeType::query() - ->select(['id', 'name']) + ->select(['ulid', 'name']) ->latest() ->get() - ->pluck('name', 'id')); + ->pluck('name', 'ulid')); } } diff --git a/app/Domains/Admission/Queries/SearchAdmissionSchedulePricing.php b/app/Domains/Admission/Queries/SearchAdmissionSchedulePricing.php new file mode 100644 index 0000000..d006090 --- /dev/null +++ b/app/Domains/Admission/Queries/SearchAdmissionSchedulePricing.php @@ -0,0 +1,37 @@ +whereHas('admissionSchedule', fn ($query) => $query + ->whereRelationUlid('admissionTrack', $admissionTrack) + ->whereHas('term', fn ($query) => $query->where('name', $termEntry)) + ) + ->whereRelationUlid('academicProgram', $academicProgram) + ->whereRelationUlid('studyProgram', $studyProgram) + ->whereRelationUlid('feeType', $feeType) + ->select(['amount', 'id']) + ->first(); + + if(is_null($fee)) { + throw new Exception(__('domains/admission/exception.fee_rate_not_found')); + } + + return $fee; + } +} diff --git a/app/Domains/Billing/Actions/Catalog/ModifyProduct.php b/app/Domains/Billing/Actions/Catalog/ModifyProduct.php deleted file mode 100644 index c7a96b4..0000000 --- a/app/Domains/Billing/Actions/Catalog/ModifyProduct.php +++ /dev/null @@ -1,23 +0,0 @@ -update([ - 'name' => $dto->name, - 'code' => $dto->code, - 'coa_id' => $dto->coaId, - ]); - - return $product->fresh(); - }); - } -} diff --git a/app/Domains/Billing/Actions/Catalog/RegisterProduct.php b/app/Domains/Billing/Actions/Catalog/RegisterProduct.php deleted file mode 100644 index b9f6bfb..0000000 --- a/app/Domains/Billing/Actions/Catalog/RegisterProduct.php +++ /dev/null @@ -1,21 +0,0 @@ - $dto->name, - 'code' => $dto->code, - 'coa_id' => $dto->coaId, - ]); - }); - } -} diff --git a/app/Domains/Billing/Actions/Catalog/SetChartOfAccountForProduct.php b/app/Domains/Billing/Actions/Catalog/SetChartOfAccountForProduct.php new file mode 100644 index 0000000..e88ddd4 --- /dev/null +++ b/app/Domains/Billing/Actions/Catalog/SetChartOfAccountForProduct.php @@ -0,0 +1,18 @@ +update([ + 'chart_of_account_id' => $coaId, + ]); + }); + } +} diff --git a/app/Domains/Billing/Actions/Discounts/ReloveDiscount.php b/app/Domains/Billing/Actions/Discounts/ReloveDiscount.php new file mode 100644 index 0000000..05ccb01 --- /dev/null +++ b/app/Domains/Billing/Actions/Discounts/ReloveDiscount.php @@ -0,0 +1,11 @@ + $item->code, + 'name' => $item->name, + 'quantity' => $item->quantity, + 'gross_amount' => $item->pricing->gross_amount, + 'discount_amount' => $item->pricing->discount_amount, + 'net_amount' => $item->pricing->net_amount, + 'invoice_id' => $invoiceId, + 'product_id' => $item->product_id, + 'chart_of_account_id' => $item->coa_id + ]); + } +} diff --git a/app/Domains/Billing/Actions/Invoicing/DetachUnusedItemsFromInvoice.php b/app/Domains/Billing/Actions/Invoicing/DetachUnusedItemsFromInvoice.php new file mode 100644 index 0000000..1001e2b --- /dev/null +++ b/app/Domains/Billing/Actions/Invoicing/DetachUnusedItemsFromInvoice.php @@ -0,0 +1,11 @@ +generateInvoiceCode->execute($dto->client_id); + + $invoice = Invoice::create([ + 'code' => $code, + 'name' => $dto->name, + 'client_detail' => $dto->client_detail, + 'client_ref_id' => $dto->client_ref_id, + 'type' => $dto->type, + 'gross_amount' => $dto->pricing->gross_amount, + 'discount_amount' => $dto->pricing->discount_amount, + 'net_amount' => $dto->pricing->net_amount, + 'status' => InvoiceStatus::DRAFT, + 'client_id' => $dto->client_id + ]); + + foreach ($dto->items as $item) { + $this->attachItemsToInvoice->execute(invoiceId: $invoice->id, item: $item); + } + + return $invoice; + }); + } catch (\Throwable $th) { + info($th->getMessage()); + throw new Exception(__('domains/system.exception.something_wrong')); + } } -} \ No newline at end of file +} diff --git a/app/Domains/Billing/Actions/Invoicing/GenerateInvoiceCode.php b/app/Domains/Billing/Actions/Invoicing/GenerateInvoiceCode.php new file mode 100644 index 0000000..c639746 --- /dev/null +++ b/app/Domains/Billing/Actions/Invoicing/GenerateInvoiceCode.php @@ -0,0 +1,45 @@ +format('Ym'); + + $basePrefix = sprintf('INV-%s-%s-', $token, $yearMonth); + + return DB::transaction(function () use ($basePrefix) { + // Find the last generated code with lock + $lastInvoice = Invoice::query() + ->where('code', 'LIKE', "{$basePrefix}%") + ->lockForUpdate() + ->orderByDesc('id') + ->first(); + + if (! $lastInvoice) { + $nextSequence = 1; + } else { + // Extract sequence number from the tail end of string + $parts = explode('-', $lastInvoice->code); + $lastSequence = (int) end($parts); + $nextSequence = $lastSequence + 1; + } + + // Zero-pad sequence to 5 digits (e.g., 00001, 00002) + $paddedSequence = str_pad((string) $nextSequence, 5, '0', STR_PAD_LEFT); + + return $basePrefix . $paddedSequence; + }); + } +} diff --git a/app/Domains/Billing/Actions/Invoicing/IssueInvoice.php b/app/Domains/Billing/Actions/Invoicing/IssueInvoice.php new file mode 100644 index 0000000..44b892e --- /dev/null +++ b/app/Domains/Billing/Actions/Invoicing/IssueInvoice.php @@ -0,0 +1,25 @@ +draftInvoice->execute($invoiceDTO); + $this->updateInvoiceStatus->execute($invoice, InvoiceStatus::ISSUED); + } +} diff --git a/app/Domains/Billing/Actions/Invoicing/UpdateInvoiceDetail.php b/app/Domains/Billing/Actions/Invoicing/UpdateInvoiceDetail.php new file mode 100644 index 0000000..460373c --- /dev/null +++ b/app/Domains/Billing/Actions/Invoicing/UpdateInvoiceDetail.php @@ -0,0 +1,38 @@ +update(['client_detail' => $dto->client_detail]); + $invoice->items()->delete(); + + foreach ($dto->items as $item) { + $this->attachItemsToInvoice->execute(invoiceId: $invoice->id, item: $item); + } + + return $invoice; + }); + } catch (\Throwable $th) { + info($th->getMessage()); + throw new Exception(__('domains/system.exception.something_wrong')); + } + } +} diff --git a/app/Domains/Billing/Actions/Invoicing/UpdateInvoiceStatus.php b/app/Domains/Billing/Actions/Invoicing/UpdateInvoiceStatus.php new file mode 100644 index 0000000..d5927cb --- /dev/null +++ b/app/Domains/Billing/Actions/Invoicing/UpdateInvoiceStatus.php @@ -0,0 +1,14 @@ +update(['status' => $status]); + } +} diff --git a/app/Domains/Billing/DTOs/Catalog/ModifyProductDTO.php b/app/Domains/Billing/DTOs/Catalog/ModifyProductDTO.php deleted file mode 100644 index 9791ac9..0000000 --- a/app/Domains/Billing/DTOs/Catalog/ModifyProductDTO.php +++ /dev/null @@ -1,12 +0,0 @@ - $items + * @param int $client_id + */ + public function __construct( + public string $name, + public string $client_ref_id, + public array $client_detail, + public InvoiceType $type, + public PricingDTO $pricing, + public array $items, + public int $client_id, + ) {} +} diff --git a/app/Domains/Billing/DTOs/Invoicing/InvoiceItemDTO.php b/app/Domains/Billing/DTOs/Invoicing/InvoiceItemDTO.php new file mode 100644 index 0000000..d45d6b8 --- /dev/null +++ b/app/Domains/Billing/DTOs/Invoicing/InvoiceItemDTO.php @@ -0,0 +1,38 @@ +toInt() * $this->quantity; + + $this->pricing = new PricingDTO( + gross_amount: $price, + discount_amount: $discount ?: new Money(0), + net_amount: new Money($netAmount), + ); + } +} diff --git a/app/Domains/Billing/DTOs/Invoicing/PricingDTO.php b/app/Domains/Billing/DTOs/Invoicing/PricingDTO.php new file mode 100644 index 0000000..b2795b3 --- /dev/null +++ b/app/Domains/Billing/DTOs/Invoicing/PricingDTO.php @@ -0,0 +1,19 @@ + "secondary", + self::ISSUED => "info", + self::PARTIALLY_PAID => "warning", + self::PAID => "success", + self::VOID => "danger", + }; + } } diff --git a/app/Domains/Billing/Enums/Invoicing/InvoiceType.php b/app/Domains/Billing/Enums/Invoicing/InvoiceType.php index eaee8bb..757ea3e 100644 --- a/app/Domains/Billing/Enums/Invoicing/InvoiceType.php +++ b/app/Domains/Billing/Enums/Invoicing/InvoiceType.php @@ -2,9 +2,39 @@ namespace App\Domains\Billing\Enums\Invoicing; -enum InvoiceType: string +use App\Domains\Billing\Support\Schemas\Invoice\CustomerInvoiceSchema; +use App\Domains\Billing\Support\Schemas\Invoice\StudentInvoiceSchema; +use App\Domains\System\Traits\Enum\HasPredicateMethod; +use App\UI\Enums\Concerns\InteractsWithLabels; +use App\UI\Enums\Contracts\HasLabel; +use App\UI\Enums\Contracts\HasUiBadge; + +enum InvoiceType: string implements HasLabel, HasUiBadge { + use HasPredicateMethod; + use InteractsWithLabels; + case STUDENT_INVOICE = 'student_invoice'; case VENDOR_BILL = 'vendor_bill'; case CREDIT_NOTE = 'credit_note'; + + public function schema(): ?CustomerInvoiceSchema + { + return match ($this) { + self::STUDENT_INVOICE => StudentInvoiceSchema::make(), + default => CustomerInvoiceSchema::make() + }; + } + + /** + * @return string + */ + public function variant(): string + { + return match ($this) { + self::STUDENT_INVOICE => 'primary', + self::VENDOR_BILL => 'tertiary', + self::CREDIT_NOTE => 'warning', + }; + } } diff --git a/app/Domains/Billing/Models/Invoice.php b/app/Domains/Billing/Models/Invoice.php index 7f8ecba..2ce7ec0 100644 --- a/app/Domains/Billing/Models/Invoice.php +++ b/app/Domains/Billing/Models/Invoice.php @@ -2,11 +2,15 @@ namespace App\Domains\Billing\Models; +use App\Domains\Billing\Enums\Invoicing\InvoiceStatus; +use App\Domains\Billing\Enums\Invoicing\InvoiceType; +use App\Domains\System\Casts\MoneyCurrency; use Database\Factories\Billing\InvoiceFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\UseFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ 'code', @@ -24,4 +28,18 @@ use Illuminate\Database\Eloquent\Model; class Invoice extends Model { use HasFactory; + + protected $casts = [ + 'client_detail' => 'array', + 'type' => InvoiceType::class, + 'gross_amount' => MoneyCurrency::class, + 'discount_amount' => MoneyCurrency::class, + 'net_amount' => MoneyCurrency::class, + 'status' => InvoiceStatus::class, + ]; + + public function items(): HasMany + { + return $this->hasMany(InvoiceItem::class); + } } diff --git a/app/Domains/Billing/Models/InvoiceItem.php b/app/Domains/Billing/Models/InvoiceItem.php index 1335195..2dd9b81 100644 --- a/app/Domains/Billing/Models/InvoiceItem.php +++ b/app/Domains/Billing/Models/InvoiceItem.php @@ -2,6 +2,7 @@ namespace App\Domains\Billing\Models; +use App\Domains\System\Casts\MoneyCurrency; use Database\Factories\Billing\InvoiceItemFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -13,26 +14,26 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 'code', 'name', 'quantity', - 'unit_price', 'gross_amount', 'discount_amount', 'net_amount', 'invoice_id', 'product_id', - 'coa_id' + 'chart_of_account_id' ])] #[UseFactory(InvoiceItemFactory::class)] class InvoiceItem extends Model { use HasFactory; + protected $casts = [ + 'gross_amount' => MoneyCurrency::class, + 'discount_amount' => MoneyCurrency::class, + 'net_amount' => MoneyCurrency::class, + ]; + public function product(): BelongsTo { return $this->belongsTo(Product::class); } - - public function chartOfAccount(): BelongsTo - { - return $this->belongsTo(ChartOfAccount::class); - } } diff --git a/app/Domains/Billing/Models/Product.php b/app/Domains/Billing/Models/Product.php index 9243e16..232d376 100644 --- a/app/Domains/Billing/Models/Product.php +++ b/app/Domains/Billing/Models/Product.php @@ -7,7 +7,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\UseFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; #[Fillable([ @@ -15,18 +14,13 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; 'name', 'pricing_type', 'pricing_id', - 'coa_id' + 'chart_of_account_id' ])] #[UseFactory(ProductFactory::class)] class Product extends Model { use HasFactory; - public function chartOfAccount(): BelongsTo - { - return $this->belongsTo(ChartOfAccount::class, 'coa_id'); - } - public function pricing(): MorphTo { return $this->morphTo(); diff --git a/app/Domains/Billing/Policies/ProductPolicy.php b/app/Domains/Billing/Policies/ProductPolicy.php new file mode 100644 index 0000000..6a9cb62 --- /dev/null +++ b/app/Domains/Billing/Policies/ProductPolicy.php @@ -0,0 +1,37 @@ +can('product.viewAny'); + } + + public function view(User $user, Product $model): bool + { + return $user->can('product.view'); + } + + public function create(User $user): bool + { + return $user->can('product.create'); + } + + public function update(User $user, Product $model): bool + { + return $user->can('product.update'); + } + + public function delete(User $user, Product $model): bool + { + return $user->can('product.delete'); + } +} diff --git a/app/Domains/Billing/Queries/Catalog/SearchProductInfo.php b/app/Domains/Billing/Queries/Catalog/SearchProductInfo.php new file mode 100644 index 0000000..ccd4bb0 --- /dev/null +++ b/app/Domains/Billing/Queries/Catalog/SearchProductInfo.php @@ -0,0 +1,16 @@ +where('pricing_id', $model_id) + ->select(['id', 'code', 'name', 'chart_of_account_id']) + ->first(); + } +} diff --git a/app/Domains/Billing/Support/Schemas/Invoice/CustomerInvoiceSchema.php b/app/Domains/Billing/Support/Schemas/Invoice/CustomerInvoiceSchema.php new file mode 100644 index 0000000..ce387e6 --- /dev/null +++ b/app/Domains/Billing/Support/Schemas/Invoice/CustomerInvoiceSchema.php @@ -0,0 +1,24 @@ +canAddItems = false; + return $this; + } + + public function isCanAddItems(): bool { + return $this->canAddItems; + } + + public function isCannotAddItems(): bool { + return !$this->canAddItems; + } +} diff --git a/app/Domains/Billing/Support/Schemas/Invoice/StudentInvoiceSchema.php b/app/Domains/Billing/Support/Schemas/Invoice/StudentInvoiceSchema.php new file mode 100644 index 0000000..0b56bc8 --- /dev/null +++ b/app/Domains/Billing/Support/Schemas/Invoice/StudentInvoiceSchema.php @@ -0,0 +1,118 @@ +cannotAddItems() + ->addInput( + InputSchema::make() + ->key('no_register') + ->label(__('domains/billing/field.invoice.detail.no_register')) + ->type(InputType::TEXTLINE) + ->rules(['required', 'string']) + ) + ->addInput( + InputSchema::make() + ->key('student_name') + ->label(__('domains/billing/field.invoice.detail.student_name')) + ->type(InputType::TEXTLINE) + ->rules(['required', 'string']) + ) + ->addInput( + InputSchema::make() + ->key('admission_track') + ->label(__('domains/billing/field.invoice.detail.admission_track')) + ->type(InputType::SELECT) + ->rules(['required', 'string']) + ->select2() + ->withLabel() + ->options(fn() => AdmissionTrackLookup::fetch()) + ) + ->addInput( + InputSchema::make() + ->key('academic_program') + ->label(__('domains/billing/field.invoice.detail.academic_program')) + ->type(InputType::SELECT) + ->rules(['required', 'string']) + ->select2() + ->withLabel() + ->options(fn() => AcademicProgramLookup::fetch()) + ) + ->addInput( + InputSchema::make() + ->key('faculty') + ->label(__('domains/billing/field.invoice.detail.faculty')) + ->type(InputType::SELECT) + ->rules(['required', 'string']) + ->select2() + ->live() + ->withLabel() + ->options(fn () => FacultyLookup::fetch()) + ) + ->addInput( + InputSchema::make() + ->key('study_program') + ->label(__('domains/billing/field.invoice.detail.study_program')) + ->type(InputType::SELECT) + ->rules(['required', 'string']) + ->select2() + ->live() + ->dependsOn('faculty') + ->setContextKeys(['faculty']) + ->withLabel() + ->options(fn ($context) => StudyProgramLookup::fetch($context)) + ) + ->addInput( + InputSchema::make() + ->key('class_year') + ->label(__('domains/billing/field.invoice.detail.class_year')) + ->type(InputType::TEXTLINE) + ->rules(['required', 'string']) + ) + ->addInput( + InputSchema::make() + ->key('term_entry') + ->label(__('domains/billing/field.invoice.detail.term_entry')) + ->type(InputType::SELECT) + ->rules(['required']) + ->select2() + ->options(fn () => TermLookup::fetch()) + ) + ->addInput( + InputSchema::make() + ->key('semester') + ->label(__('domains/billing/field.invoice.detail.semester')) + ->type(InputType::SELECT) + ->rules(['required']) + ->select2() + ->options(array_combine(range(1, 14), range(1, 14))) + ) + ->addInput( + InputSchema::make() + ->key('fee_type') + ->label(__('domains/billing/field.invoice.detail.fee_type')) + ->type(InputType::SELECT) + ->rules(['required']) + ->select2() + ->withLabel() + ->options(fn() => FeeTypeLookup::fetch()) + ); + } +} diff --git a/app/Domains/Billing/Support/Schemas/Invoice/VendorInvoiceSchema.php b/app/Domains/Billing/Support/Schemas/Invoice/VendorInvoiceSchema.php new file mode 100644 index 0000000..135c909 --- /dev/null +++ b/app/Domains/Billing/Support/Schemas/Invoice/VendorInvoiceSchema.php @@ -0,0 +1,20 @@ +addInput( + InputSchema::make() + ); + } +} diff --git a/app/Domains/Channel/Actions/Api/SynchronizeWithApi.php b/app/Domains/Channel/Actions/Api/SynchronizeWithApi.php index 6ca5278..5a3d02d 100644 --- a/app/Domains/Channel/Actions/Api/SynchronizeWithApi.php +++ b/app/Domains/Channel/Actions/Api/SynchronizeWithApi.php @@ -35,7 +35,6 @@ class SynchronizeWithApi $response = Http::withHeaders($header)->get($url); $result = $response->json()['data']; - app(RunGenericImportPipeline::class)->execute( rows: collect($result), mapper: $mapper, diff --git a/app/Domains/System/Casts/MoneyCurrency.php b/app/Domains/System/Casts/MoneyCurrency.php index 3bb8c2f..c975b96 100644 --- a/app/Domains/System/Casts/MoneyCurrency.php +++ b/app/Domains/System/Casts/MoneyCurrency.php @@ -25,7 +25,7 @@ class MoneyCurrency implements CastsAttributes } if ($value instanceof Money) { - return $value->toFloat(); + return $value->toInt(); } if (is_numeric($value)) { diff --git a/app/Domains/System/Providers/SystemServiceProvider.php b/app/Domains/System/Providers/SystemServiceProvider.php index 474322a..21ba82a 100644 --- a/app/Domains/System/Providers/SystemServiceProvider.php +++ b/app/Domains/System/Providers/SystemServiceProvider.php @@ -10,6 +10,7 @@ use App\Domains\System\Listeners\Excel\SendImportReportEmail; use App\Domains\System\Listeners\Files\RemoveUserFiles; use App\Domains\System\Queries\GetSystemSettings; use App\Domains\System\Traits\Provider\RegistersDomainEvents; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; @@ -49,6 +50,9 @@ class SystemServiceProvider extends ServiceProvider Carbon::macro('toUserTz', fn () => $this->copy() ->tz(config('app.display_timezone', 'UTC'))); + Builder::macro('whereRelationUlid', function ($relation, $ulid) { + return $this->whereHas($relation, fn($builder) => $builder->where('ulid', $ulid)); + }); Str::macro('initials', function (string $name, int $limit = 2) { $words = explode(' ', trim($name)); diff --git a/app/Domains/System/Support/ValueObjects/Money.php b/app/Domains/System/Support/ValueObjects/Money.php index dcbc520..291ba70 100644 --- a/app/Domains/System/Support/ValueObjects/Money.php +++ b/app/Domains/System/Support/ValueObjects/Money.php @@ -3,12 +3,13 @@ namespace App\Domains\System\Support\ValueObjects; use InvalidArgumentException; +use Livewire\Wireable; use NumberFormatter; use Stringable; -class Money implements Stringable +readonly class Money implements Stringable, Wireable { - public function __construct(private readonly float $amount) + public function __construct(private int $amount) { if ($this->amount < 0) { throw new InvalidArgumentException('Money cannot be negative.'); @@ -25,7 +26,7 @@ class Money implements Stringable return $format->formatCurrency($this->amount, $currency); } - public function toFloat(): float + public function toInt(): int { return $this->amount; } @@ -37,4 +38,23 @@ class Money implements Stringable { return $this->format(); } + + /** + * @return mixed + */ + public function toLivewire(): mixed + { + return [ + 'amount' => $this->amount, + ]; + } + + /** + * @param $value + * @return self + */ + public static function fromLivewire($value): static + { + return new static($value['amount']); + } } diff --git a/app/Http/Controllers/Web/Billing/InvoiceController.php b/app/Http/Controllers/Web/Billing/InvoiceController.php new file mode 100644 index 0000000..9dd80e8 --- /dev/null +++ b/app/Http/Controllers/Web/Billing/InvoiceController.php @@ -0,0 +1,32 @@ + 'dashboard', + 'domains/billing/seo.invoice.title' => '', + ], + )] + public function __invoke(InvoiceDataTable $dataTable) + { + return $dataTable->render('pages.billing.invoices.index'); + } +} diff --git a/app/Http/DataTables/Academic/FacultyDataTable.php b/app/Http/DataTables/Academic/FacultyDataTable.php index 3167598..65cd034 100644 --- a/app/Http/DataTables/Academic/FacultyDataTable.php +++ b/app/Http/DataTables/Academic/FacultyDataTable.php @@ -23,7 +23,6 @@ class FacultyDataTable extends DataTable ->addColumn( 'action', fn (Faculty $faculty) => view('components.datatables.action-button', [ - 'log' => true, 'edit' => [ 'modal' => 'faculty-form-modal', 'permission' => auth()->user()->can('update', $faculty), diff --git a/app/Http/DataTables/Academic/StudyProgramDataTable.php b/app/Http/DataTables/Academic/StudyProgramDataTable.php index c30b0dd..c43a7c5 100644 --- a/app/Http/DataTables/Academic/StudyProgramDataTable.php +++ b/app/Http/DataTables/Academic/StudyProgramDataTable.php @@ -27,7 +27,6 @@ class StudyProgramDataTable extends DataTable ->addColumn( 'action', fn (StudyProgram $studyProgram) => view('components.datatables.action-button', [ - 'log' => true, 'edit' => [ 'modal' => 'studyprogram-form-modal', 'permission' => auth()->user()->can('update', $studyProgram), diff --git a/app/Http/DataTables/Academic/TermDataTable.php b/app/Http/DataTables/Academic/TermDataTable.php index 8254432..5d98559 100644 --- a/app/Http/DataTables/Academic/TermDataTable.php +++ b/app/Http/DataTables/Academic/TermDataTable.php @@ -23,7 +23,6 @@ class TermDataTable extends DataTable ->addColumn( 'action', fn (Term $term) => view('components.datatables.action-button', [ - 'log' => true, 'edit' => [ 'modal' => 'term-form-modal', 'permission' => auth()->user()->can('update', $term), diff --git a/app/Http/DataTables/Admission/AcademicProgramDataTable.php b/app/Http/DataTables/Admission/AcademicProgramDataTable.php index ed15ccf..96c5808 100644 --- a/app/Http/DataTables/Admission/AcademicProgramDataTable.php +++ b/app/Http/DataTables/Admission/AcademicProgramDataTable.php @@ -27,7 +27,6 @@ class AcademicProgramDataTable extends DataTable ->addColumn( 'action', fn ($academicProgram) => view('components.datatables.action-button', [ - 'log' => true, 'edit' => [ 'modal' => 'academicprogram-form-modal', 'permission' => auth()->user()->can('update', $academicProgram), diff --git a/app/Http/DataTables/Admission/AdmissionScheduleDataTable.php b/app/Http/DataTables/Admission/AdmissionScheduleDataTable.php index 8f4ec31..b379cbf 100644 --- a/app/Http/DataTables/Admission/AdmissionScheduleDataTable.php +++ b/app/Http/DataTables/Admission/AdmissionScheduleDataTable.php @@ -25,7 +25,6 @@ class AdmissionScheduleDataTable extends DataTable ->editColumn( 'action', fn (AdmissionSchedule $admissionSchedule) => view('components.datatables.action-button', [ - 'log' => true, 'view' => [ 'url' => route('admission-schedules.view', ['id' => $admissionSchedule->ulid]), 'permission' => auth()->user()->can('view', $admissionSchedule), diff --git a/app/Http/DataTables/Admission/AdmissionTrackDataTable.php b/app/Http/DataTables/Admission/AdmissionTrackDataTable.php index cc73b96..5baf96c 100644 --- a/app/Http/DataTables/Admission/AdmissionTrackDataTable.php +++ b/app/Http/DataTables/Admission/AdmissionTrackDataTable.php @@ -27,7 +27,6 @@ class AdmissionTrackDataTable extends DataTable ->addColumn( 'action', fn ($admissionTrack) => view('components.datatables.action-button', [ - 'log' => true, 'edit' => [ 'modal' => 'admissiontrack-form-modal', 'permission' => auth()->user()->can('update', $admissionTrack), diff --git a/app/Http/DataTables/Admission/FeeTypeDataTable.php b/app/Http/DataTables/Admission/FeeTypeDataTable.php index 0a1df5c..3e3a865 100644 --- a/app/Http/DataTables/Admission/FeeTypeDataTable.php +++ b/app/Http/DataTables/Admission/FeeTypeDataTable.php @@ -24,7 +24,6 @@ class FeeTypeDataTable extends DataTable ->addColumn( 'action', fn ($feeType) => view('components.datatables.action-button', [ - 'log' => true, 'delete' => [ 'url' => null, 'title' => __('ui/button.delete'), diff --git a/app/Http/DataTables/Billing/InvoiceDataTable.php b/app/Http/DataTables/Billing/InvoiceDataTable.php new file mode 100644 index 0000000..84a67af --- /dev/null +++ b/app/Http/DataTables/Billing/InvoiceDataTable.php @@ -0,0 +1,136 @@ + $query Results from query() method. + */ + public function dataTable(QueryBuilder $query): EloquentDataTable + { + return (new EloquentDataTable($query)) + ->editColumn('net_amount', fn(Invoice $invoice) => $invoice->net_amount->format()) + ->editColumn('type', fn(Invoice $invoice) => view('components.badge', [ + 'label' => $invoice->type->label(), + 'variant' => $invoice->type->variant(), + ])) + ->editColumn('status', fn(Invoice $invoice) => view('components.badge', [ + 'label' => $invoice->status->label(), + 'variant' => $invoice->status->variant(), + ])) + ->addColumn( + 'action', + fn (Invoice $invoice) => view('components.datatables.action-button', [ + 'view' => [ + 'url' => route('invoices.view', ['id' => $invoice]), + 'permission' => true, + ], + 'edit' => [ + 'url' => route('invoices.update', ['invoice' => $invoice]), + 'permission' => $invoice->status->isDraft(), + ], + 'table_name' => 'invoice-table', + 'id' => $invoice->ulid, + ]) + ) + ->addIndexColumn(); + } + + /** + * Get the query source of dataTable. + * + * @return QueryBuilder + */ + public function query(Invoice $model): QueryBuilder + { + return $model->newQuery(); + } + + /** + * Optional method if you want to use the html builder. + */ + public function html(): HtmlBuilder + { + return $this->builder() + ->setTableId('invoice-table') + ->columns($this->getColumns()) + ->minifiedAjax() + ->orderBy(1) + ->layout([ + 'topStart' => [ + 'className' => 'col-md-auto me-auto d-flex flex-sm-row flex-column justify-content-center justify-content-md-start align-items-center align-items-md-start gap-1', + 'features' => ['buttons', 'pageLength'], + ], + 'topEnd' => [ + 'className' => 'col-md-auto ms-auto d-flex flex-sm-row flex-column justify-content-center justify-content-md-end align-items-center align-items-md-start gap-1', + 'features' => ['search'], + ], + + 'bottomStart' => 'info', + 'bottomEnd' => 'paging', + ]) + ->parameters([ + 'language' => [ + 'search' => '', + 'searchPlaceholder' => __('ui/button.lookup'), + ], + ]) + ->buttons([ + Button::make('add') + ->text(svg('tabler-plus', ['width' => 16, 'height' => 16])->toHtml()) + ->addClass('btn-sm'), + Button::make('reload') + ->text(svg('tabler-reload', ['width' => 16, 'height' => 16])->toHtml()) + ->addClass('btn-sm'), + ]); + } + + /** + * Get the dataTable columns definition. + */ + public function getColumns(): array + { + return [ + Column::computed('DT_RowIndex') + ->title('#') + ->searchable(false) + ->orderable(false), + Column::make('code') + ->title(__('domains/billing/field.invoice.code')), + Column::make('name') + ->title(__('domains/billing/field.invoice.name')), + Column::make('type') + ->title(__('domains/billing/field.invoice.type')), + Column::make('net_amount') + ->title(__('domains/billing/field.invoice.net_amount')), + Column::make('status') + ->title(__('domains/billing/field.invoice.status')), + Column::computed('action') + ->title(__('ui/label.actions')) + ->exportable(false) + ->printable(false) + ->width(60) + ->addClass('text-center'), + ]; + } + + /** + * Get the filename for export. + */ + protected function filename(): string + { + return 'InvoiceDataTable_' . date('YmdHis'); + } +} diff --git a/app/Http/DataTables/Billing/ProductDataTable.php b/app/Http/DataTables/Billing/ProductDataTable.php index 46c8020..c68055e 100644 --- a/app/Http/DataTables/Billing/ProductDataTable.php +++ b/app/Http/DataTables/Billing/ProductDataTable.php @@ -20,7 +20,7 @@ class ProductDataTable extends DataTable public function dataTable(QueryBuilder $query): EloquentDataTable { return (new EloquentDataTable($query)) - ->editColumn('chart_of_account.name', fn (Product $product) => $product->chartOfAccount?->name ?? '-') + ->editColumn('chart_of_account.name', fn (Product $product) => $product->chartOfAccount?->code ?? '-') ->addColumn( 'action', fn (Product $product) => view('components.datatables.action-button', [ @@ -86,10 +86,6 @@ class ProductDataTable extends DataTable 'responsive' => true, ]) ->buttons([ - Button::make('add') - ->action('$("#product-form-modal").modal("show");') - ->text(svg('tabler-plus', ['width' => 16, 'height' => 16])->toHtml()) - ->addClass('btn-sm'), Button::make('reload') ->text(svg('tabler-reload', ['width' => 16, 'height' => 16])->toHtml()) ->addClass('btn-sm'), diff --git a/app/Http/DataTables/Channel/ClientDataTable.php b/app/Http/DataTables/Channel/ClientDataTable.php index cff95a6..9ee432b 100644 --- a/app/Http/DataTables/Channel/ClientDataTable.php +++ b/app/Http/DataTables/Channel/ClientDataTable.php @@ -24,7 +24,6 @@ class ClientDataTable extends DataTable ->addColumn( 'action', fn (Client $client) => view('components.datatables.action-button', [ - 'log' => true, 'edit' => [ 'modal' => 'client-form-modal', 'permission' => auth()->user()->can('update', $client), diff --git a/app/Http/Middleware/HandleSeoSetting.php b/app/Http/Middleware/HandleSeoSetting.php index edf47ac..bf8bcb8 100644 --- a/app/Http/Middleware/HandleSeoSetting.php +++ b/app/Http/Middleware/HandleSeoSetting.php @@ -30,6 +30,10 @@ class HandleSeoSetting config(['seotools.opengraph.defaults.description' => $description]); config(['seotools.json-ld.defaults.description' => $description]); + if (! in_array(app()->getLocale(), config('app.indexed_locales', []))) { + config(['seotools.meta.defaults.robots' => 'noindex, nofollow']); + } + return $next($request); } } diff --git a/app/Livewire/Concerns/HasModelFill.php b/app/Livewire/Concerns/HasModelFill.php new file mode 100644 index 0000000..4385e56 --- /dev/null +++ b/app/Livewire/Concerns/HasModelFill.php @@ -0,0 +1,84 @@ + Array and BackedEnum conversions. + * + * @param mixed $values Data array or Eloquent Model instance. + * @return void + * @throws ReflectionException + */ + public function fill(mixed $values = []): void + { + $data = $values instanceof Model ? $values->attributesToArray() : (array) $values; + + foreach (get_object_vars($this) as $property => $defaultValue) { + $hasModelValue = $values instanceof Model && isset($values->{$property}); + + if (! array_key_exists($property, $data) && ! $hasModelValue) { + continue; + } + + $value = $values instanceof Model ? $values->{$property} : $data[$property]; + + $refProperty = new ReflectionProperty($this, $property); + $type = $refProperty->getType(); + + if($type) { + $typeName = null; + if ($type instanceof \ReflectionNamedType) { + $typeName = $type->getName(); + } elseif ($type instanceof \ReflectionUnionType) { + $typeName = (string) $type; + } + + if ($typeName === 'array' && $value instanceof Collection) { + $value = $value->toArray(); + } + + if (($typeName === Collection::class || is_subclass_of($typeName, Collection::class)) && is_array($value)) { + $value = collect($value); + } + + if (enum_exists($typeName) && ! ($value instanceof BackedEnum) && $value !== null) { + $value = $typeName::tryFrom((string) $value); + } + } + + // Assign hydrated value back to the Form property + $this->{$property} = $value; + } + } + + /** + * Dehydrate form properties into a clean array representation suitable for DTOs or Models. + * Converts Collections to arrays and Enums to primitive values. + * + * @return array + */ + public function toModelArray(): array + { + $result = []; + + foreach (get_object_vars($this) as $property => $value) { + if ($value instanceof Collection) { + $result[$property] = $value->toArray(); + } elseif ($value instanceof BackedEnum) { + $result[$property] = $value->value; + } else { + $result[$property] = $value; + } + } + + return $result; + } +} diff --git a/app/Livewire/Forms/Accounting/ChartOfAccountForm.php b/app/Livewire/Forms/Accounting/ChartOfAccountForm.php index 2c0ff8e..5711d51 100644 --- a/app/Livewire/Forms/Accounting/ChartOfAccountForm.php +++ b/app/Livewire/Forms/Accounting/ChartOfAccountForm.php @@ -5,11 +5,14 @@ namespace App\Livewire\Forms\Accounting; use App\Domains\Accounting\Enums\Ledger\AccountBalance; use App\Domains\Accounting\Enums\Ledger\AccountClassification; use App\Domains\System\Enums\LifecycleStatus; +use App\Livewire\Concerns\HasModelFill; use Livewire\Attributes\Validate; use Livewire\Form; class ChartOfAccountForm extends Form { + use HasModelFill; + #[Validate('required|string|max:255', as: 'domains/accounting/field.coa.code')] public string $code = ''; diff --git a/app/Livewire/Forms/Billing/InvoiceForm.php b/app/Livewire/Forms/Billing/InvoiceForm.php new file mode 100644 index 0000000..c5a4bb9 --- /dev/null +++ b/app/Livewire/Forms/Billing/InvoiceForm.php @@ -0,0 +1,165 @@ +gross_amount = new Money(0); + $this->discount_amount = new Money(0); + $this->net_amount = new Money(0); + } + + public function addItem( + string $code, + string $name, + int $quantity, + Money $price, + Money $discount, + int $product_id, + ?int $coa_id + ): void + { + $netAmount = $price->toInt() * $quantity; + + $this->items[] = [ + 'code' => $code, + 'name' => $name, + 'quantity' => $quantity, + 'gross_amount' => $price, + 'discount_amount' => $discount, + 'net_amount' => new Money($netAmount), + 'product_id' => $product_id, + 'chart_of_account_id' => $coa_id, + ]; + } + + public function populatePricing(): void + { + $this->gross_amount = new Money(collect($this->items)->sum(fn($arr) => $arr['gross_amount']->toInt())); + $this->discount_amount = new Money(collect($this->items)->sum(fn($arr) => $arr['discount_amount']->toInt())); + $this->net_amount = new Money(collect($this->items)->sum(fn($arr) => $arr['net_amount']->toInt())); + } + + public function updatedClientDetail($val, $prop): void + { + $formSchema = $this->type->schema()->getSchema(); + + $this->resetValidation(); + foreach ($formSchema as $key => $schema) { + /** @var InputSchemaField $schema */ + $schema = $schema->schema->getSchema(); + if ($schema->dependsOn == $prop) { + $this->client_detail[$key] = ''; + } + } + $this->validateOnly($prop); + } + + public function resetSchema(): void + { + $this->reset('client_detail'); + + $schema = $this->type?->schema()->getSchema() ?? []; + + foreach ($schema as $value) { + $this->client_detail[$value->key] = ''; + } + + $this->resetValidation(); + } + + public function rules(): array + { + return $this->type?->schema()?->getValidationRules('client_detail') ?? []; + } + + public function validationAttributes(): array + { + return $this->type?->schema()?->getValidationAttributes('client_detail') ?? []; + } + + public function toDTO(): InvoiceDTO + { + $items = []; + + $detail = $this->type?->schema()?->resolveInputSnapshots($this->client_detail); + foreach ($this->items as $item) { + $itemDTO = new InvoiceItemDTO( + code: $item['code'], + name: $item['name'], + quantity: $item['quantity'], + product_id: $item['product_id'], + coa_id: $item['chart_of_account_id'], + ); + $itemDTO->setPricing( + price: $item['gross_amount'], + discount: $item['discount_amount'], + ); + + + $items[] = $itemDTO; + } + + return new InvoiceDTO( + name: $this->name, + client_ref_id: $this->client_ref_id, + client_detail: $detail, + type: $this->type, + pricing: new PricingDTO( + gross_amount: $this->gross_amount, + discount_amount: $this->discount_amount, + net_amount: $this->net_amount, + ), + items: $items, + client_id: $this->client_id + ); + } +} diff --git a/app/Livewire/Forms/Finance/InvoiceForm.php b/app/Livewire/Forms/Finance/InvoiceForm.php deleted file mode 100644 index 4cf4801..0000000 --- a/app/Livewire/Forms/Finance/InvoiceForm.php +++ /dev/null @@ -1,82 +0,0 @@ -type->schema()->getSchema(); - - $this->resetValidation(); - foreach ($formSchema as $key => $schema) { - /** @var InputSchemaField $schema */ - $schema = $schema->schema->getSchema(); - if ($schema->dependsOn == $prop) { - $this->detail[$key] = ''; - } - } - $this->validateOnly($prop); - } - - public function updatedType(): void - { - $this->reset('detail'); - - $schema = $this->type?->getSchema() ?? []; - foreach ($schema as $value) { - $this->detail[$value->key] = ''; - } - - $this->resetValidation(); - } - - public function rules(): array - { - return $this->type?->schema()?->getValidationRules('detail') ?? []; - } - - public function validationAttributes(): array - { - return $this->type?->schema()?->getValidationAttributes('detail') ?? []; - } - - public function toDto(): IssueInvoiceDTO - { - return new IssueInvoiceDTO( - name: $this->name, - clientRefId: $this->client_id, - detail: $this->type->dtoTransform($this->detail), - type: $this->type, - status: InvoiceStatus::PENDING, - amount: $this->amount, - coaId: $this->coa_id, - clientId: $this->client_id, - ); - } -} diff --git a/app/UI/Support/Schemas/AbstractGroupSchema.php b/app/UI/Support/Schemas/AbstractGroupSchema.php index 5eb7eb4..87705a1 100644 --- a/app/UI/Support/Schemas/AbstractGroupSchema.php +++ b/app/UI/Support/Schemas/AbstractGroupSchema.php @@ -14,12 +14,37 @@ abstract class AbstractGroupSchema extends AbstractSchema */ public function addInput(InputSchema $input): static { - $inputSchema = $input->getSchema(); + $inputSchema = $input->withContext($this->context)->getSchema(); $this->fields[$inputSchema->key] = $input; return $this; } + /** + * Resolve input values along with option label snapshots. + * + * @param array $inputValues + * @return array + */ + public function resolveInputSnapshots(array $inputValues): array + { + $resolved = $inputValues; + + foreach ($this->fields as $key => $schema) { + $fieldSchema = $schema->getSchema(); + $value = $inputValues[$key] ?? null; + + if ($fieldSchema->hasLabelSnapshot && ! blank($value)) { + $labelKey = $fieldSchema->labelKey; + + // Calling resolveOptionLabel from InputSchema + $resolved[$labelKey] = $schema->resolveOptionLabel($value, $inputValues); + } + } + + return $resolved; + } + /** * Pass dynamic context down to all child fields. */ diff --git a/app/UI/Support/Schemas/InputSchema.php b/app/UI/Support/Schemas/InputSchema.php index 24a1be1..d0b34d5 100644 --- a/app/UI/Support/Schemas/InputSchema.php +++ b/app/UI/Support/Schemas/InputSchema.php @@ -28,6 +28,9 @@ class InputSchema extends AbstractSchema protected bool $live = false; + protected bool $withLabel = false; + protected string $labelKey = ''; + /** * Set the field unique key / name. */ @@ -83,6 +86,54 @@ class InputSchema extends AbstractSchema return $this; } + /** + * Enable auto-attaching option label snapshot whenever selected option changes. + * + * @param string|null $customKey Optional custom key name (defaults to {key}_label). + */ + public function withLabel(?string $customKey = null): static + { + $this->withLabel = true; + $this->labelKey = $customKey ?: "{$this->key}_label"; + + return $this; + } + + /** + * Resolve display label text for selected value ID from the dynamic options list. + * + * @param mixed $selectedValue Selected option key/ID value. + * @param array $extraContext Dynamic context passed to closure options. + * @return string|null + */ + public function resolveOptionLabel(mixed $selectedValue, array $extraContext = []): ?string + { + // Return null if no option selected + if (blank($selectedValue)) { + return null; + } + + // Fetch options array or Collection + $options = $this->resolveOptions($extraContext); + + if ($options instanceof Collection) { + $options = $options->toArray(); + } + + // Return matched label from option array or a fallback to raw value + return $options[$selectedValue] ?? (string) $selectedValue; + } + + public function hasLabelSnapshot(): bool + { + return $this->withLabel; + } + + public function getLabelKey(): ?string + { + return $this->labelKey ?: "{$this->key}_label"; + } + public function setContextKeys(array|string $keys): static { $this->contextKeys = (array) $keys; @@ -154,7 +205,9 @@ class InputSchema extends AbstractSchema default: $this->default, isLive: $this->isLive(), dependsOn: $this->depends, + hasLabelSnapshot: $this->hasLabelSnapshot(), schema: $this, + labelKey: $this->getLabelKey(), ); } } diff --git a/app/UI/Support/Schemas/InputSchemaField.php b/app/UI/Support/Schemas/InputSchemaField.php index b903a8a..0387b17 100644 --- a/app/UI/Support/Schemas/InputSchemaField.php +++ b/app/UI/Support/Schemas/InputSchemaField.php @@ -15,6 +15,8 @@ readonly class InputSchemaField public mixed $default, public bool $isLive, public string $dependsOn, + public bool $hasLabelSnapshot = false, public InputSchema $schema, + public ?string $labelKey = null, ) {} } diff --git a/config/app.php b/config/app.php index 423eed5..dff5537 100644 --- a/config/app.php +++ b/config/app.php @@ -84,6 +84,8 @@ return [ 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + 'indexed_locales' => ['en', 'id'], + /* |-------------------------------------------------------------------------- | Encryption Key diff --git a/database/migrations/2026_07_06_031003_create_clients_table.php b/database/migrations/2026_07_06_031003_create_clients_table.php index 01a3976..fc346ce 100644 --- a/database/migrations/2026_07_06_031003_create_clients_table.php +++ b/database/migrations/2026_07_06_031003_create_clients_table.php @@ -19,7 +19,7 @@ return new class extends Migration $table->text('secret'); $table->text('auth_type'); $table->text('webhook'); - $table->longText('credentials'); + $table->longText('credentials')->default('{}'); $table->timestamps(); }); } diff --git a/database/migrations/2026_09_08_022815_create_chart_of_accounts_table.php b/database/migrations/2026_09_08_022815_create_chart_of_accounts_table.php index 174400f..3e2f038 100644 --- a/database/migrations/2026_09_08_022815_create_chart_of_accounts_table.php +++ b/database/migrations/2026_09_08_022815_create_chart_of_accounts_table.php @@ -19,7 +19,7 @@ return new class extends Migration $table->string('classification'); $table->string('normal_balance'); $table->foreignId('parent_id')->nullable(); - $table->string('status'); + $table->string('status')->default('active'); $table->timestamps(); }); } diff --git a/database/migrations/2026_09_08_022818_create_invoice_items_table.php b/database/migrations/2026_09_08_022818_create_invoice_items_table.php index 5ebea72..fb8b3e5 100644 --- a/database/migrations/2026_09_08_022818_create_invoice_items_table.php +++ b/database/migrations/2026_09_08_022818_create_invoice_items_table.php @@ -16,13 +16,15 @@ return new class extends Migration $table->string('code'); $table->string('name'); $table->integer('quantity'); - $table->integer('unit_price'); $table->integer('gross_amount'); $table->integer('discount_amount'); $table->integer('net_amount'); $table->foreignId('invoice_id')->constrained(); $table->foreignId('product_id')->constrained(); - $table->foreignId('chart_of_account_id')->constrained(); + $table->foreignId('chart_of_account_id') + ->nullable() + ->constrained() + ->nullOnDelete(); $table->timestamps(); }); } diff --git a/database/seeders/Accounting/ChartOfAccountSeeder.php b/database/seeders/Accounting/ChartOfAccountSeeder.php new file mode 100644 index 0000000..e0da217 --- /dev/null +++ b/database/seeders/Accounting/ChartOfAccountSeeder.php @@ -0,0 +1,93 @@ + '41000'], + [ + 'name' => 'Pendapatan Operasional', + 'classification' => AccountClassification::REVENUE, + 'normal_balance' => AccountBalance::CREDIT, + 'parent_id' => null, + ] + ); + + // Define child sub-account definitions array matching campus operational ledger + $accounts = [ + [ + // Asset account for Bank BNI VA Receipts + 'code' => '1102', + 'name' => 'Bank BNI Penerimaan VA', + 'classification' => AccountClassification::ASSET, + 'normal_balance' => AccountBalance::DEBIT, + 'parent_id' => null, + ], + [ + // Asset account for Student Receivables + 'code' => '1105', + 'name' => 'Piutang Mahasiswa', + 'classification' => AccountClassification::ASSET, + 'normal_balance' => AccountBalance::DEBIT, + 'parent_id' => null, + ], + [ + // Sub-COA 41101: New Student Registration Fee (Pendapatan Pendaftaran Maba) + 'code' => '41101', + 'name' => 'Pendapatan Pendaftaran Maba-Reguler', + 'classification' => AccountClassification::REVENUE, + 'normal_balance' => AccountBalance::CREDIT, + 'parent_id' => $revenueParent->id, + ], + [ + // Sub-COA 41200: Re-registration Fee (Pendapatan Kemahasiswaan / Daftar Ulang) + 'code' => '41200', + 'name' => 'Pendapatan Kemahasiswaan / Daftar Ulang', + 'classification' => AccountClassification::REVENUE, + 'normal_balance' => AccountBalance::CREDIT, + 'parent_id' => $revenueParent->id, + ], + [ + // Sub-COA 41301: Regular Tuition Fee (Pendapatan UKT-Reguler) + 'code' => '41301', + 'name' => 'Pendapatan UKT-Reguler', + 'classification' => AccountClassification::REVENUE, + 'normal_balance' => AccountBalance::CREDIT, + 'parent_id' => $revenueParent->id, + ], + [ + // Sub-COA 4201: Contra Revenue for Discounts (Diskon & Potongan Tagihan) + 'code' => '4201', + 'name' => 'Diskon PMDK & Potongan Tagihan', + 'classification' => AccountClassification::REVENUE, + 'normal_balance' => AccountBalance::DEBIT, // Debit offsets credit revenue + 'parent_id' => null, + ], + ]; + + // Loop and upsert each account entry into database + foreach ($accounts as $account) { + ChartOfAccount::updateOrCreate( + ['code' => $account['code']], + $account + ); + } + } +} diff --git a/database/seeders/Channel/ClientSeeder.php b/database/seeders/Channel/ClientSeeder.php new file mode 100644 index 0000000..1ee7465 --- /dev/null +++ b/database/seeders/Channel/ClientSeeder.php @@ -0,0 +1,83 @@ + 'SIAKAD', + 'domain' => 'http://admin.siakad.test', + 'webhook' => '/webhook', + 'auth_type' => AuthType::NONE, + 'credentials' => [], + 'secret' => sha1('secret'.time()) + ]); + $pmb = Client::create([ + 'name' => 'PMB', + 'domain' => 'http://pmb.test', + 'webhook' => '/api/webhook', + 'auth_type' => AuthType::NONE, + 'credentials' => [], + 'secret' => sha1('secret'.time()) + ]); + + $apiEndpoints = [ + [ + 'key' => ApiType::FACULTY, + 'value' => '/InternalApi/list_data/fakultas', + 'client_id' => $siakad->id + ], + [ + 'key' => ApiType::STUDY_PROGRAM, + 'value' => '/InternalApi/list_data/prodi', + 'client_id' => $siakad->id + ], + [ + 'key' => ApiType::TERM, + 'value' => '/InternalApi/list_data/semester', + 'client_id' => $siakad->id + ], + [ + 'key' => ApiType::ADMISSION_TRACK, + 'value' => '/api/jalur-pendaftaran', + 'client_id' => $pmb->id + ], + [ + 'key' => ApiType::ACADEMIC_PROGRAM, + 'value' => '/api/sistem-kuliah', + 'client_id' => $pmb->id + ], + [ + 'key' => ApiType::ADMISSION_SCHEDULE, + 'value' => '/api/jadwal-pendaftaran', + 'client_id' => $pmb->id + ], + [ + 'key' => ApiType::FEE_TYPE, + 'value' => '/api/jenis-tagihan', + 'client_id' => $pmb->id + ], + [ + 'key' => ApiType::FEE_RATE, + 'value' => '/api/biaya-pendaftaran', + 'client_id' => $pmb->id + ], + ]; + + foreach ($apiEndpoints as $endpoint) { + ApiEndpoint::create($endpoint); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 534d80c..f5326c0 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,7 +2,8 @@ namespace Database\Seeders; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; +use Database\Seeders\Accounting\ChartOfAccountSeeder; +use Database\Seeders\Channel\ClientSeeder; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder @@ -15,5 +16,7 @@ class DatabaseSeeder extends Seeder $this->call(RoleSeeder::class); $this->call(UserSeeder::class); $this->call(SystemSettingSeeder::class); + $this->call(ClientSeeder::class); + $this->call(ChartOfAccountSeeder::class); } } diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..e478e2d --- /dev/null +++ b/lang/en.json @@ -0,0 +1,6 @@ +{ + "The email has already been verified.": "The email has already been verified.", + "The verification email has been sent.": "The verification email has been sent.", + "components.layouts.guest": "Guest Layout", + "components.layouts.app": "App Layout" +} diff --git a/lang/en/domains/account/enum.php b/lang/en/domains/account/enum.php index c5e3252..9bf085d 100644 --- a/lang/en/domains/account/enum.php +++ b/lang/en/domains/account/enum.php @@ -1,30 +1,8 @@ [ - GenderOption::MALE->value => 'Male', - GenderOption::FEMALE->value => 'Female', - ], - 'user_setting_key' => [ - 'notification' => 'Notification', - 'language' => 'Language', - 'timezone' => 'Timezone', - 'options' => [ - 'language' => [ - 'en' => 'English', - 'id' => 'Indonesian', - ], - 'notification' => [ - 'on' => 'On', - 'off' => 'Off', - ], - ], - ], - 'user_settings' => [ - 'notification' => 'Notification', - 'language' => 'Language', - 'timezone' => 'Timezone', + 'male' => 'Male', + 'female' => 'Female', ], ]; diff --git a/lang/en/domains/account/pages.php b/lang/en/domains/account/pages.php index d6b896c..41183bd 100644 --- a/lang/en/domains/account/pages.php +++ b/lang/en/domains/account/pages.php @@ -3,15 +3,7 @@ return [ 'profile' => [ 'title' => 'Profile Information', - 'description' => "Update your account's profile information and email address.", - ], - 'password' => [ - 'title' => 'Update Password', - 'description' => 'Ensure your account is using a long, random password to stay secure.', - ], - 'delete_account' => [ - 'title' => 'Delete Account', - 'description' => 'Once your account is deleted, all of its resources and data will be permanently deleted.', + 'description' => 'Update your account\'s profile information and email address.', ], 'user_settings' => [ 'title' => 'User Settings', diff --git a/lang/en/domains/admission/exception.php b/lang/en/domains/admission/exception.php new file mode 100644 index 0000000..9527632 --- /dev/null +++ b/lang/en/domains/admission/exception.php @@ -0,0 +1,5 @@ + 'Admission fee rate not found.', +]; \ No newline at end of file diff --git a/lang/en/domains/admission/field.php b/lang/en/domains/admission/field.php index ca92468..df94c2a 100644 --- a/lang/en/domains/admission/field.php +++ b/lang/en/domains/admission/field.php @@ -7,11 +7,9 @@ return [ 're_registration_date_start' => 'Re-registration Start Date', 're_registration_date_end' => 'Re-registration End Date', 'track_name' => 'Track Name', - 'status' => 'Status', 'term_name' => 'Term', ], 'admission_track' => [ - 'code' => 'Track Code', 'name' => 'Admission Track Name', 'status' => 'Status', ], diff --git a/lang/en/domains/admission/seo.php b/lang/en/domains/admission/seo.php index ec20dea..4b2568e 100644 --- a/lang/en/domains/admission/seo.php +++ b/lang/en/domains/admission/seo.php @@ -9,7 +9,7 @@ return [ 'admission_schedule_detail' => [ 'title' => 'Admission Schedule Detail', 'description' => 'View details of the admission schedule.', - 'keywords' => 'admission schedule detail, admission, schedule', + 'keywords' => 'admission track schedule, admission schedule, new student admission schedule.', ], 'admission_track' => [ 'title' => 'Admission Track Data', diff --git a/lang/en/domains/auth/pages.php b/lang/en/domains/auth/pages.php index 48b8681..fe6cb0f 100644 --- a/lang/en/domains/auth/pages.php +++ b/lang/en/domains/auth/pages.php @@ -3,7 +3,7 @@ return [ 'login' => [ 'header' => 'Sign in to your account', - 'no_account' => "Don't have an account?", + 'no_account' => 'Don\'t have an account?', 'register_link' => 'Sign up', ], 'register' => [ @@ -20,9 +20,7 @@ return [ 'header' => 'Reset Password', ], 'verify_email' => [ - 'header' => 'Verify Email', - 'subheader' => "Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn't receive the email, we will gladly send you another.", - 'resend_link' => 'A new verification link has been sent to the email address you provided during registration.', + 'subheader' => 'Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.', ], 'confirm_password' => [ 'header' => 'Confirm Password', diff --git a/lang/en/domains/auth/seo.php b/lang/en/domains/auth/seo.php index fb7612a..a34b269 100644 --- a/lang/en/domains/auth/seo.php +++ b/lang/en/domains/auth/seo.php @@ -21,11 +21,6 @@ return [ 'description' => 'Set a new password for your account.', 'keywords' => 'reset password, auth', ], - 'verify_email' => [ - 'title' => 'Verify Email', - 'description' => 'Verify your email address to secure your account.', - 'keywords' => 'verify email, auth', - ], 'confirm_password' => [ 'title' => 'Confirm Password', 'description' => 'Please confirm your password before continuing.', diff --git a/lang/en/domains/billing.php b/lang/en/domains/billing.php new file mode 100644 index 0000000..44b53c6 --- /dev/null +++ b/lang/en/domains/billing.php @@ -0,0 +1,10 @@ + [ + 'invoice' => [ + 'draft_success' => 'Invoice draft saved successfully.', + 'issue_success' => 'Invoice issued successfully.', + ], + ], +]; \ No newline at end of file diff --git a/lang/en/domains/billing/button.php b/lang/en/domains/billing/button.php new file mode 100644 index 0000000..6043655 --- /dev/null +++ b/lang/en/domains/billing/button.php @@ -0,0 +1,8 @@ + 'Issue Invoice', + 'issue_this_invoice' => 'Issue This Invoice', + 'check_student_admission' => 'Check Student Admission', + 'save_invoice_draft' => 'Save Invoice Draft', +]; diff --git a/lang/en/domains/billing/enum.php b/lang/en/domains/billing/enum.php new file mode 100644 index 0000000..90f19a7 --- /dev/null +++ b/lang/en/domains/billing/enum.php @@ -0,0 +1,16 @@ + [ + 'student_invoice' => 'Student Invoice', + 'vendor_bill' => 'Vendor Bill', + 'credit_note' => 'Credit Note', + ], + 'invoice_status' => [ + 'draft' => 'Draft', + 'issued' => 'Issued', + 'partially_paid' => 'Partially Paid', + 'paid' => 'Paid', + 'void' => 'Void', + ], +]; diff --git a/lang/en/domains/billing/field.php b/lang/en/domains/billing/field.php index ca1a271..994763f 100644 --- a/lang/en/domains/billing/field.php +++ b/lang/en/domains/billing/field.php @@ -6,4 +6,34 @@ return [ 'name' => 'Product Name', 'coa' => 'Chart of Account (COA)', ], + 'invoice' => [ + 'code' => 'Invoice Code', + 'name' => 'Invoice Name', + 'net_amount' => 'Net Amount', + 'status' => 'Status', + 'type' => 'Type', + 'gross_amount' => 'Gross Amount', + 'discount_amount' => 'Discount Amount', + 'client_name' => 'Client Name', + 'detail' => [ + 'no_register' => 'Registration Number', + 'student_name' => 'Student Name', + 'admission_track' => 'Admission Track', + 'academic_program' => 'Academic Program', + 'faculty' => 'Faculty', + 'study_program' => 'Study Program', + 'class_year' => 'Class Year', + 'term_entry' => 'Term Entry', + 'semester' => 'Semester', + 'fee_type' => 'Fee Type', + ], + ], + 'invoice_item' => [ + 'code' => 'Item Code', + 'name' => 'Item Name', + 'quantity' => 'Quantity', + 'gross_price' => 'Gross Price', + 'discounted_price' => 'Discounted Price', + 'net_price' => 'Net Price', + ], ]; diff --git a/lang/en/domains/billing/messages.php b/lang/en/domains/billing/messages.php new file mode 100644 index 0000000..7a5e2c0 --- /dev/null +++ b/lang/en/domains/billing/messages.php @@ -0,0 +1,5 @@ + 'Invoice issued successfully.', +]; \ No newline at end of file diff --git a/lang/en/domains/billing/section.php b/lang/en/domains/billing/section.php new file mode 100644 index 0000000..8a2ae33 --- /dev/null +++ b/lang/en/domains/billing/section.php @@ -0,0 +1,19 @@ + [ + 'customer_detail' => [ + 'title' => 'Customer Details', + 'subtitle' => 'Detailed information about the student or customer.', + ], + 'summary' => [ + 'title' => 'Invoice Summary', + 'subtitle' => 'Breakdown of total amounts for this invoice.', + ], + 'items' => [ + 'title' => 'Invoice Items', + 'subtitle' => 'List of products or services included in this invoice.', + ], + 'select_invoice_type' => 'Select Invoice Type', + ], +]; diff --git a/lang/en/domains/billing/seo.php b/lang/en/domains/billing/seo.php index b04c65b..e9d3970 100644 --- a/lang/en/domains/billing/seo.php +++ b/lang/en/domains/billing/seo.php @@ -6,4 +6,12 @@ return [ 'description' => 'Manage products and their chart of account mappings.', 'keywords' => 'product, products, billing, finance, coa', ], + 'invoice' => [ + 'title' => 'Invoice Data', + 'description' => 'Manage invoices and billing transactions.', + 'keywords' => 'invoice, billing, transaction, finance', + ], + 'create_invoice' => [ + 'title' => 'Create Invoice', + ], ]; diff --git a/lang/en/domains/channel/field.php b/lang/en/domains/channel/field.php index b7c885a..42aa13e 100644 --- a/lang/en/domains/channel/field.php +++ b/lang/en/domains/channel/field.php @@ -9,7 +9,6 @@ return [ 'credentials' => [ 'username' => 'Username', 'password' => 'Password', - 'token' => 'Token', 'bearer' => 'Bearer Token', ], 'endpoint_url' => 'Endpoint URL', diff --git a/lang/en/domains/identity/enum.php b/lang/en/domains/identity/enum.php index 593740f..daacfed 100644 --- a/lang/en/domains/identity/enum.php +++ b/lang/en/domains/identity/enum.php @@ -1,17 +1,9 @@ [ - UserStatus::ACTIVE->value => 'Active', - UserStatus::INACTIVE->value => 'Inactive', - ], - 'role_type' => [ - RoleType::SYSTEM_ADMIN->value => 'System Administrator', - RoleType::ADMIN->value => 'Administrator', - RoleType::USER->value => 'User', + 'active' => 'Active', + 'inactive' => 'Inactive', ], 'user_setting_key' => [ 'notification' => 'Notification', diff --git a/lang/en/domains/identity/messages.php b/lang/en/domains/identity/messages.php index 2095ee7..636da44 100644 --- a/lang/en/domains/identity/messages.php +++ b/lang/en/domains/identity/messages.php @@ -2,14 +2,9 @@ return [ 'success' => [ - 'password_reset_link_sent' => 'Password reset link has been sent.', 'user_role_updated' => 'User role has been updated.', - 'user_status_updated' => 'User status has been updated.', ], 'exceptions' => [ - 'failed_to_send_password_reset_link' => 'Failed to send password reset link.', - 'failed_to_update_user_role' => 'Failed to update user role.', - 'failed_to_update_user_status' => 'Failed to update user status.', 'user_already_active' => 'This user is already active.', 'user_already_suspended' => 'This user is already suspended.', 'user_already_status' => 'User is already in this status.', diff --git a/lang/en/domains/identity/notifications.php b/lang/en/domains/identity/notifications.php index 628ea38..1a301d7 100644 --- a/lang/en/domains/identity/notifications.php +++ b/lang/en/domains/identity/notifications.php @@ -1,11 +1,6 @@ [ - 'subject' => 'Welcome!', - 'body' => 'Your account is ready.', - 'action' => 'Login', - ], 'governance' => [ 'user_suspended' => [ 'subject' => 'Account Suspended', @@ -26,4 +21,30 @@ return [ 'outro' => 'If you have any questions, please contact our support team.', ], ], + 'auth' => [ + 'reset_password' => [ + 'subject' => 'Reset Password Notification', + 'intro' => 'You are receiving this email because we received a password reset request for your account.', + 'action' => 'Reset Password', + 'outro' => 'This password reset link will expire in :count minutes. If you did not request a password reset, no further action is required.', + ], + 'sign_in_activity' => [ + 'subject' => 'New Sign-in Activity Detected', + 'greeting' => 'Hello, :name!', + 'intro' => 'A new sign-in was detected on your account at :time.', + 'details' => [ + 'ip' => 'IP Address: :ip', + 'browser' => 'Browser: :browser', + ], + 'warning' => 'If this was not you, please secure your account immediately.', + 'action' => 'Go to Dashboard', + 'outro' => 'Thank you for using our application!', + ], + 'verify_email' => [ + 'subject' => 'Verify Email Address', + 'intro' => 'Please click the button below to verify your email address.', + 'action' => 'Verify Email Address', + 'outro' => 'If you did not create an account, no further action is required.', + ], + ], ]; diff --git a/lang/en/domains/identity/pages.php b/lang/en/domains/identity/pages.php index 18659d6..dfe2dfd 100644 --- a/lang/en/domains/identity/pages.php +++ b/lang/en/domains/identity/pages.php @@ -11,10 +11,7 @@ return [ ], 'confirmation' => [ 'toggle_status' => 'Are you sure you want to change the status of this user?', - 'status_changed' => 'User status has been successfully updated.', - 'status_unchanged' => 'User status was not changed.', 'send_password_reset' => 'Are you sure you want to send a password reset link to this user?', - 'password_reset_success' => 'Password reset link has been successfully sent.', ], ], ]; diff --git a/lang/en/domains/identity/seo.php b/lang/en/domains/identity/seo.php index b6ab2a3..657be01 100644 --- a/lang/en/domains/identity/seo.php +++ b/lang/en/domains/identity/seo.php @@ -6,11 +6,6 @@ return [ 'description' => 'Manage user accounts, roles, and permissions.', 'keywords' => 'user management, users, identity', ], - 'user_detail' => [ - 'title' => 'Detail of :name', - 'description' => 'Manage user accounts, roles, and permissions.', - 'keywords' => 'user management, users, identity', - ], 'role' => [ 'title' => 'Role Management', 'description' => 'Manage user roles and permissions.', diff --git a/lang/en/domains/system.php b/lang/en/domains/system.php new file mode 100644 index 0000000..1c50a68 --- /dev/null +++ b/lang/en/domains/system.php @@ -0,0 +1,7 @@ + [ + 'something_wrong' => 'Something went wrong. Please try again later.', + ], +]; \ No newline at end of file diff --git a/lang/en/domains/system/enum.php b/lang/en/domains/system/enum.php index 00e0a1a..3a31c86 100644 --- a/lang/en/domains/system/enum.php +++ b/lang/en/domains/system/enum.php @@ -1,26 +1,23 @@ [ - LifecycleStatus::ACTIVE->value => 'Active', - LifecycleStatus::INACTIVE->value => 'Inactive', + 'active' => 'Active', + 'inactive' => 'Inactive', ], 'system_setting_key' => [ - SystemSettingKey::WEB_NAME->value => 'Website Name', - SystemSettingKey::WEB_DESCRIPTION->value => 'Website Description', - SystemSettingKey::WEB_LOGO->value => 'Website Logo', - SystemSettingKey::WEB_FAVICON->value => 'Website Favicon', - SystemSettingKey::WEB_PHONE->value => 'Website Phone', - SystemSettingKey::WEB_EMAIL->value => 'Website Email', - SystemSettingKey::WEB_ADDRESS->value => 'Website Address', - SystemSettingKey::DEFAULT_LANGUAGE->value => 'Default Language', - SystemSettingKey::TIMEZONE->value => 'Timezone', - SystemSettingKey::GOOGLE_TAG_MANAGER_ID->value => 'Google Tag Manager ID', - SystemSettingKey::GOOGLE_WEBMASTER_ID->value => 'Google Webmaster ID', - SystemSettingKey::CURRENT_TERM->value => 'Current Term', + 'web-name' => 'Website Name', + 'web-description' => 'Website Description', + 'web-logo' => 'Website Logo', + 'web-favicon' => 'Website Favicon', + 'web-phone' => 'Website Phone', + 'web-email' => 'Website Email', + 'web-address' => 'Website Address', + 'default_language' => 'Default Language', + 'timezone' => 'Timezone', + 'google-tag_manager_id' => 'Google Tag Manager ID', + 'google-webmaster_id' => 'Google Webmaster ID', + 'current_term' => 'Current Term', ], 'system_setting_key_options' => [ 'default_language' => [ diff --git a/lang/en/domains/system/field.php b/lang/en/domains/system/field.php index 5d5802c..fd8d1d7 100644 --- a/lang/en/domains/system/field.php +++ b/lang/en/domains/system/field.php @@ -4,23 +4,6 @@ return [ 'backup' => [ 'file' => 'Backup File', ], - 'settings' => [ - 'web' => [ - 'name' => 'Web Name', - 'description' => 'Web Description', - 'address' => 'Web Address', - 'phone' => 'Web Phone', - 'email' => 'Web Email', - 'logo' => 'Web Logo', - 'favicon' => 'Web Favicon', - ], - 'default_language' => 'Default Language', - 'timezone' => 'Timezone', - 'google' => [ - 'tag_manager_id' => 'Google Tag Manager ID', - 'webmaster_id' => 'Google Webmaster ID', - ], - ], 'audit' => [ 'ip_address' => 'IP Address', 'browser' => 'Browser', diff --git a/lang/en/domains/system/pages.php b/lang/en/domains/system/pages.php index 3fb7ec8..a26e28d 100644 --- a/lang/en/domains/system/pages.php +++ b/lang/en/domains/system/pages.php @@ -2,7 +2,6 @@ return [ 'backup' => [ - 'title' => 'System Backup Catalogs', 'backup_button' => 'Back Up', 'upload_button' => 'Upload Backup File', 'upload_modal_title' => 'Upload Backup File', @@ -12,17 +11,10 @@ return [ ], ], 'settings' => [ - 'update_title' => 'Update Settings', 'sections' => [ 'web' => 'Web', 'general' => 'General', 'webmaster' => 'Webmaster', ], ], - 'api' => [ - 'sections' => [ - 'academic' => 'Academic', - 'admission' => 'Admission', - ], - ], ]; diff --git a/lang/en/event.php b/lang/en/event.php index 63f2bf5..40304cc 100644 --- a/lang/en/event.php +++ b/lang/en/event.php @@ -4,5 +4,6 @@ return [ 'created' => 'Created', 'updated' => 'Updated', 'deleted' => 'Deleted', + 'restored' => 'Restored', 'permissions_synced' => 'Permissions synced', ]; diff --git a/lang/en/permissions.php b/lang/en/permissions.php index e52b83d..e8d0977 100644 --- a/lang/en/permissions.php +++ b/lang/en/permissions.php @@ -2,7 +2,6 @@ return [ 'dashboard' => [ - 'group-name' => 'Dashboard', 'index' => 'Grants authority to access and view the data summaries on the main application dashboard.', 'admin' => 'Provides specialized access to the administrator control panel for comprehensive system monitoring.', 'user' => 'Provides access to a personalized dashboard containing information relevant to general users.', @@ -24,11 +23,106 @@ return [ 'delete' => 'Provides authority to remove role levels that are no longer required by the system.', ], 'system-setting' => [ - 'group-name' => 'System Setting', 'manage' => 'Allows to view and manage system setting', + 'api' => 'Allows to view and manage system api setting', ], 'system-backup' => [ - 'group-name' => 'System Backup', 'manage' => 'Allows to view and manage system backup', ], + 'faculty' => [ + 'group-name' => 'Faculty', + 'viewAny' => 'Allows users to view and search the list of all faculties.', + 'view' => 'Allows users to view the details of a specific faculty.', + 'create' => 'Allows to create new faculty.', + 'update' => 'Allows to update faculty details.', + 'delete' => 'Allows to delete faculty.', + ], + 'study-program' => [ + 'group-name' => 'Study Program', + 'viewAny' => 'Allows users to view and search the list of all study programs.', + 'view' => 'Allows users to view the details of a specific study program.', + 'create' => 'Allows to create new study program.', + 'update' => 'Allows to update study program details.', + 'delete' => 'Allows to delete study program.', + ], + 'term' => [ + 'group-name' => 'Academic Term', + 'viewAny' => 'Allows users to view and search the list of all academic terms.', + 'view' => 'Allows users to view the details of a specific academic term.', + 'create' => 'Allows to create new academic term.', + 'update' => 'Allows to update academic term details.', + 'delete' => 'Allows to delete academic term.', + ], + 'admission-track' => [ + 'group-name' => 'Admission Track', + 'viewAny' => 'Allows users to view and search the list of all admission tracks.', + 'view' => 'Allows users to view the details of a specific admission track.', + 'create' => 'Allows to create new admission track.', + 'update' => 'Allows to update admission track details.', + 'delete' => 'Allows to delete admission track.', + ], + 'admission-schedule' => [ + 'group-name' => 'Admission Schedule', + 'viewAny' => 'Allows users to view and search the list of all admission schedules.', + 'view' => 'Allows users to view the details of a specific admission schedule.', + 'create' => 'Allows to create new admission schedule.', + 'update' => 'Allows to update admission schedule details.', + 'delete' => 'Allows to delete admission schedule.', + ], + 'academic-program' => [ + 'group-name' => 'Academic Program', + 'viewAny' => 'Allows users to view and search the list of all academic programs.', + 'view' => 'Allows users to view the details of a specific academic program.', + 'create' => 'Allows to create new academic program.', + 'update' => 'Allows to update academic program details.', + 'delete' => 'Allows to delete academic program.', + ], + 'fee-type' => [ + 'group-name' => 'Fee Type', + 'viewAny' => 'Allows users to view and search the list of all fee types.', + 'view' => 'Allows users to view the details of a specific fee type.', + 'create' => 'Allows to create new fee type.', + 'update' => 'Allows to update fee type details.', + 'delete' => 'Allows to delete fee type.', + ], + 'client' => [ + 'group-name' => 'Client', + 'viewAny' => 'Allows users to view and search the list of all clients.', + 'view' => 'Allows users to view the details of a specific client.', + 'create' => 'Allows to create new client.', + 'update' => 'Allows to update client details.', + 'delete' => 'Allows to delete client.', + ], + 'coa' => [ + 'group-name' => 'Chart of Account (COA)', + 'viewAny' => 'Allows users to view and search the list of all COAs.', + 'view' => 'Allows users to view the details of a specific COA.', + 'create' => 'Allows to create new COA.', + 'update' => 'Allows to update COA details.', + 'delete' => 'Allows to delete COA.', + ], + 'product' => [ + 'group-name' => 'Product', + 'viewAny' => 'Allows users to view and search the list of all products.', + 'view' => 'Allows users to view the details of a specific product.', + 'create' => 'Allows to create new product.', + 'update' => 'Allows to update product details.', + 'delete' => 'Allows to delete product.', + ], + 'discount' => [ + 'group-name' => 'Discount', + 'viewAny' => 'Allows users to view and search the list of all discounts.', + 'view' => 'Allows users to view the details of a specific discount.', + 'create' => 'Allows to create new discount.', + 'update' => 'Allows to update discount details.', + 'delete' => 'Allows to delete discount.', + ], + 'invoice' => [ + 'group-name' => 'Invoice', + 'viewAny' => 'Allows users to view and search the list of all invoices.', + 'view' => 'Allows users to view the details of a specific invoice.', + 'create' => 'Allows to create new invoice.', + 'update' => 'Allows to update invoice details.', + 'delete' => 'Allows to delete invoice.', + ], ]; diff --git a/lang/en/ui/button.php b/lang/en/ui/button.php index e33b844..b2b222a 100644 --- a/lang/en/ui/button.php +++ b/lang/en/ui/button.php @@ -3,22 +3,20 @@ return [ 'save' => 'Save Changes', 'cancel' => 'Cancel', - 'back' => 'Back', 'close' => 'Close', - 'create' => 'Create', 'update' => 'Update', - 'update-mode' => 'Enter Update Mode', 'upload' => 'Upload', 'edit' => 'Edit', 'delete' => 'Delete', 'suspend' => 'Suspend', 'view' => 'View', - 'log' => 'Log', + 'log' => 'Logs', 'yes' => 'Yes', 'no' => 'No', - 'lookup' => 'Search...', - 'logout' => 'Logout', + 'lookup' => 'Search', + 'logout' => 'Log Out', 'publish' => 'Publish', - 'sync' => 'Sync', - 'run-sync' => 'Run Synchronization', + 'run-sync' => 'Run Sync', + 'print' => 'Print', + 'back' => 'Back', ]; diff --git a/lang/en/ui/common.php b/lang/en/ui/common.php index 66aa647..e1ee6a3 100644 --- a/lang/en/ui/common.php +++ b/lang/en/ui/common.php @@ -1,5 +1,6 @@ 'No data available', 'loading' => 'Loading...', ]; diff --git a/lang/en/ui/crud.php b/lang/en/ui/crud.php index 7ac2dcb..a7fd050 100644 --- a/lang/en/ui/crud.php +++ b/lang/en/ui/crud.php @@ -9,8 +9,6 @@ return [ 'uploaded' => ':resource has been uploaded successfully.', ], 'error' => [ - 'forbidden' => 'You do not have permission to perform this action.', 'validation_failed' => 'The given data was invalid.', - 'generic' => 'Something went wrong. Please try again.', ], ]; diff --git a/lang/en/ui/greetings.php b/lang/en/ui/greetings.php index 30edcf3..0b67a5f 100644 --- a/lang/en/ui/greetings.php +++ b/lang/en/ui/greetings.php @@ -1,6 +1,3 @@ 'Good morning, :name', - 'welcome' => 'Welcome back to the dashboard', -]; +return []; diff --git a/lang/en/ui/label.php b/lang/en/ui/label.php index 2b8cdf6..5a65d05 100644 --- a/lang/en/ui/label.php +++ b/lang/en/ui/label.php @@ -1,11 +1,7 @@ 'ID', 'actions' => 'Actions', - 'created_at' => 'Created At', - 'updated_at' => 'Updated At', - 'status' => 'Status', - 'no_data' => 'No Data Available', + 'no_data' => 'No data available', 'fee_type' => 'Fee Type', ]; diff --git a/lang/en/ui/menu.php b/lang/en/ui/menu.php index b2554c5..85de780 100644 --- a/lang/en/ui/menu.php +++ b/lang/en/ui/menu.php @@ -6,7 +6,7 @@ return [ 'academic' => 'Academic', 'faculties' => 'Faculties', 'study_programs' => 'Study Programs', - 'terms' => 'Terms', + 'terms' => 'Academic Terms', 'admission' => 'Admission', 'admission_tracks' => 'Admission Tracks', 'academic_programs' => 'Academic Programs', @@ -18,10 +18,7 @@ return [ 'chart_of_accounts' => 'Chart of Accounts', 'billing' => 'Billing', 'products' => 'Products', - 'finance' => 'Finance', 'invoices' => 'Invoices', - 'product_mappings' => 'Product Mappings', - 'identity' => 'User Management', 'users' => 'Users', 'roles' => 'Roles & Permissions', 'management' => 'Management', diff --git a/lang/en/ui/notification.php b/lang/en/ui/notification.php index 9e5014f..d703d6d 100644 --- a/lang/en/ui/notification.php +++ b/lang/en/ui/notification.php @@ -3,5 +3,4 @@ return [ 'empty' => 'No notifications', 'read_all' => 'Read all notifications', - 'unread' => 'unread messages', ]; diff --git a/lang/id.json b/lang/id.json new file mode 100644 index 0000000..b59ca50 --- /dev/null +++ b/lang/id.json @@ -0,0 +1,6 @@ +{ + "The email has already been verified.": "Email sudah diverifikasi.", + "The verification email has been sent.": "Email verifikasi telah dikirim.", + "components.layouts.guest": "Tata Letak Tamu", + "components.layouts.app": "Tata Letak Aplikasi" +} diff --git a/lang/id/domains/academic/field.php b/lang/id/domains/academic/field.php index 77d076a..6482eac 100644 --- a/lang/id/domains/academic/field.php +++ b/lang/id/domains/academic/field.php @@ -17,7 +17,7 @@ return [ 'term' => [ 'name' => 'Nama Tahun Ajaran', 'code' => 'Kode Tahun Ajaran', - 'year_study' => 'Tahun Studi', + 'year_study' => 'Tahun Akademik', 'semester' => 'Semester', 'date_start' => 'Tanggal Mulai', 'date_end' => 'Tanggal Selesai', diff --git a/lang/id/domains/account/enum.php b/lang/id/domains/account/enum.php index 9bc68d3..3fd7daf 100644 --- a/lang/id/domains/account/enum.php +++ b/lang/id/domains/account/enum.php @@ -1,30 +1,8 @@ [ - GenderOption::MALE->value => 'Laki-laki', - GenderOption::FEMALE->value => 'Perempuan', - ], - 'user_setting_key' => [ - 'notification' => 'Notifikasi', - 'language' => 'Bahasa', - 'timezone' => 'Zona Waktu', - 'options' => [ - 'language' => [ - 'en' => 'Inggris', - 'id' => 'Indonesia', - ], - 'notification' => [ - 'on' => 'Aktif', - 'off' => 'Nonaktif', - ], - ], - ], - 'user_settings' => [ - 'notification' => 'Notifikasi', - 'language' => 'Bahasa', - 'timezone' => 'Zona Waktu', + 'male' => 'Laki-laki', + 'female' => 'Perempuan', ], ]; diff --git a/lang/id/domains/account/pages.php b/lang/id/domains/account/pages.php index fb0bf69..761e50a 100644 --- a/lang/id/domains/account/pages.php +++ b/lang/id/domains/account/pages.php @@ -5,14 +5,6 @@ return [ 'title' => 'Informasi Profil', 'description' => 'Perbarui informasi profil akun dan alamat email Anda.', ], - 'password' => [ - 'title' => 'Perbarui Kata Sandi', - 'description' => 'Pastikan akun Anda menggunakan kata sandi yang panjang dan acak untuk tetap aman.', - ], - 'delete_account' => [ - 'title' => 'Hapus Akun', - 'description' => 'Setelah akun Anda dihapus, semua sumber daya dan datanya akan dihapus secara permanen.', - ], 'user_settings' => [ 'title' => 'Pengaturan Pengguna', 'description' => 'Kelola preferensi aplikasi Anda seperti tema dan bahasa.', diff --git a/lang/id/domains/accounting/field.php b/lang/id/domains/accounting/field.php index 9eebfa1..d2a3c80 100644 --- a/lang/id/domains/accounting/field.php +++ b/lang/id/domains/accounting/field.php @@ -7,6 +7,6 @@ return [ 'normal_balance' => 'Saldo Normal', 'classification' => 'Klasifikasi', 'status' => 'Status', - 'parent' => 'Turunan Dari', + 'parent' => 'Induk Akun', ], ]; diff --git a/lang/id/domains/admission/exception.php b/lang/id/domains/admission/exception.php new file mode 100644 index 0000000..902b00b --- /dev/null +++ b/lang/id/domains/admission/exception.php @@ -0,0 +1,5 @@ + 'Tarif biaya pendaftaran tidak ditemukan.', +]; \ No newline at end of file diff --git a/lang/id/domains/admission/field.php b/lang/id/domains/admission/field.php index 7146d58..8140b76 100644 --- a/lang/id/domains/admission/field.php +++ b/lang/id/domains/admission/field.php @@ -7,11 +7,9 @@ return [ 're_registration_date_start' => 'Tanggal Mulai Daftar Ulang', 're_registration_date_end' => 'Tanggal Selesai Daftar Ulang', 'track_name' => 'Nama Jalur', - 'status' => 'Status', 'term_name' => 'Tahun Ajaran', ], 'admission_track' => [ - 'code' => 'Kode Jalur', 'name' => 'Nama Jalur Penerimaan', 'status' => 'Status', ], diff --git a/lang/id/domains/admission/label.php b/lang/id/domains/admission/label.php index ee368e5..dfef6f7 100644 --- a/lang/id/domains/admission/label.php +++ b/lang/id/domains/admission/label.php @@ -2,8 +2,8 @@ return [ 'fee_type' => [ - 'not_defined' => 'Not Set', - 'partially_defined' => 'Partial', - 'completely_defined' => 'Complete', + 'not_defined' => 'Belum Diatur', + 'partially_defined' => 'Sebagian', + 'completely_defined' => 'Lengkap', ], ]; diff --git a/lang/id/domains/admission/seo.php b/lang/id/domains/admission/seo.php index ddd5c81..0c38b95 100644 --- a/lang/id/domains/admission/seo.php +++ b/lang/id/domains/admission/seo.php @@ -9,7 +9,7 @@ return [ 'admission_schedule_detail' => [ 'title' => 'Detail Jadwal Penerimaan', 'description' => 'Lihat detail jadwal penerimaan.', - 'keywords' => 'detail jadwal penerimaan, penerimaan, jadwal', + 'keywords' => 'detail jalur penerimaan, penerimaan, data jalur, mahasiswa baru.', ], 'admission_track' => [ 'title' => 'Data Jalur Penerimaan', diff --git a/lang/id/domains/auth/pages.php b/lang/id/domains/auth/pages.php index 5052980..91089e5 100644 --- a/lang/id/domains/auth/pages.php +++ b/lang/id/domains/auth/pages.php @@ -20,9 +20,7 @@ return [ 'header' => 'Atur Ulang Kata Sandi', ], 'verify_email' => [ - 'header' => 'Verifikasi Email', 'subheader' => 'Terima kasih telah mendaftar! Sebelum memulai, bisakah Anda memverifikasi alamat email Anda dengan mengklik tautan yang baru saja kami kirimkan melalui email? Jika Anda tidak menerima email tersebut, kami dengan senang hati akan mengirimkan yang baru.', - 'resend_link' => 'Tautan verifikasi baru telah dikirim ke alamat email yang Anda berikan saat pendaftaran.', ], 'confirm_password' => [ 'header' => 'Konfirmasi Kata Sandi', diff --git a/lang/id/domains/auth/seo.php b/lang/id/domains/auth/seo.php index 745620d..20252e2 100644 --- a/lang/id/domains/auth/seo.php +++ b/lang/id/domains/auth/seo.php @@ -21,11 +21,6 @@ return [ 'description' => 'Tetapkan kata sandi baru untuk akun Anda.', 'keywords' => 'atur ulang kata sandi, auth', ], - 'verify_email' => [ - 'title' => 'Verifikasi Email', - 'description' => 'Verifikasi alamat email Anda untuk mengamankan akun Anda.', - 'keywords' => 'verifikasi email, auth', - ], 'confirm_password' => [ 'title' => 'Konfirmasi Kata Sandi', 'description' => 'Harap konfirmasi kata sandi Anda sebelum melanjutkan.', diff --git a/lang/id/domains/billing.php b/lang/id/domains/billing.php new file mode 100644 index 0000000..4b52b03 --- /dev/null +++ b/lang/id/domains/billing.php @@ -0,0 +1,10 @@ + [ + 'invoice' => [ + 'draft_success' => 'Draf tagihan berhasil disimpan.', + 'issue_success' => 'Tagihan berhasil diterbitkan.', + ], + ], +]; \ No newline at end of file diff --git a/lang/id/domains/billing/button.php b/lang/id/domains/billing/button.php new file mode 100644 index 0000000..f106f80 --- /dev/null +++ b/lang/id/domains/billing/button.php @@ -0,0 +1,8 @@ + 'Terbitkan Invoice', + 'issue_this_invoice' => 'Terbitkan Tagihan Ini', + 'check_student_admission' => 'Periksa Pendaftaran Mahasiswa', + 'save_invoice_draft' => 'Simpan Draf Tagihan', +]; diff --git a/lang/id/domains/billing/enum.php b/lang/id/domains/billing/enum.php new file mode 100644 index 0000000..a68cacc --- /dev/null +++ b/lang/id/domains/billing/enum.php @@ -0,0 +1,16 @@ + [ + 'student_invoice' => 'Invoice Mahasiswa', + 'vendor_bill' => 'Tagihan Vendor', + 'credit_note' => 'Nota Kredit', + ], + 'invoice_status' => [ + 'draft' => 'Draf', + 'issued' => 'Diterbitkan', + 'partially_paid' => 'Dibayar Sebagian', + 'paid' => 'Lunas', + 'void' => 'Dibatalkan', + ], +]; diff --git a/lang/id/domains/billing/field.php b/lang/id/domains/billing/field.php index cb61c5f..69e2b12 100644 --- a/lang/id/domains/billing/field.php +++ b/lang/id/domains/billing/field.php @@ -6,4 +6,34 @@ return [ 'name' => 'Nama Produk', 'coa' => 'Bagan Akun (COA)', ], + 'invoice' => [ + 'code' => 'Nomor Tagihan', + 'name' => 'Nama Tagihan', + 'net_amount' => 'Jumlah Bersih', + 'status' => 'Status', + 'type' => 'Tipe', + 'gross_amount' => 'Jumlah Kotor', + 'discount_amount' => 'Jumlah Diskon', + 'client_name' => 'Nama Klien', + 'detail' => [ + 'no_register' => 'Nomor Registrasi', + 'student_name' => 'Nama Mahasiswa', + 'admission_track' => 'Jalur Pendaftaran', + 'academic_program' => 'Program Akademik', + 'faculty' => 'Fakultas', + 'study_program' => 'Program Studi', + 'class_year' => 'Tahun Angkatan', + 'term_entry' => 'Periode Masuk', + 'semester' => 'Semester', + 'fee_type' => 'Jenis Biaya', + ], + ], + 'invoice_item' => [ + 'code' => 'Kode Item', + 'name' => 'Nama Item', + 'quantity' => 'Kuantitas', + 'gross_price' => 'Harga Kotor', + 'discounted_price' => 'Harga Diskon', + 'net_price' => 'Harga Bersih', + ], ]; diff --git a/lang/id/domains/billing/messages.php b/lang/id/domains/billing/messages.php new file mode 100644 index 0000000..43a032e --- /dev/null +++ b/lang/id/domains/billing/messages.php @@ -0,0 +1,5 @@ + 'Tagihan berhasil diterbitkan.', +]; \ No newline at end of file diff --git a/lang/id/domains/billing/section.php b/lang/id/domains/billing/section.php new file mode 100644 index 0000000..b7d3205 --- /dev/null +++ b/lang/id/domains/billing/section.php @@ -0,0 +1,19 @@ + [ + 'customer_detail' => [ + 'title' => 'Detail Pelanggan', + 'subtitle' => 'Informasi terperinci mengenai mahasiswa atau pelanggan.', + ], + 'summary' => [ + 'title' => 'Ringkasan Tagihan', + 'subtitle' => 'Rincian jumlah total untuk tagihan ini.', + ], + 'items' => [ + 'title' => 'Item Tagihan', + 'subtitle' => 'Daftar produk atau layanan yang tertera pada tagihan ini.', + ], + 'select_invoice_type' => 'Pilih Tipe Tagihan', + ], +]; diff --git a/lang/id/domains/billing/seo.php b/lang/id/domains/billing/seo.php index b140412..39d3c1b 100644 --- a/lang/id/domains/billing/seo.php +++ b/lang/id/domains/billing/seo.php @@ -3,7 +3,15 @@ return [ 'product' => [ 'title' => 'Data Produk', - 'description' => 'Kelola produk dan pemetaan bagan akun.', - 'keywords' => 'produk, billing, tagihan, coa', + 'description' => 'Kelola produk dan pemetaan bagan akun (COA).', + 'keywords' => 'produk, billing, tagihan, coa, bagan akun', + ], + 'invoice' => [ + 'title' => 'Data Tagihan', + 'description' => 'Kelola tagihan dan transaksi keuangan.', + 'keywords' => 'tagihan, invoice, penagihan, transaksi, keuangan', + ], + 'create_invoice' => [ + 'title' => 'Buat Tagihan', ], ]; diff --git a/lang/id/domains/channel/field.php b/lang/id/domains/channel/field.php index d77c70c..0aebb9c 100644 --- a/lang/id/domains/channel/field.php +++ b/lang/id/domains/channel/field.php @@ -9,7 +9,6 @@ return [ 'credentials' => [ 'username' => 'Nama Pengguna', 'password' => 'Password', - 'token' => 'Token', 'bearer' => 'Token Bearer', ], 'endpoint_url' => 'URL Endpoint', diff --git a/lang/id/domains/finance/enum.php b/lang/id/domains/finance/enum.php deleted file mode 100644 index c9204af..0000000 --- a/lang/id/domains/finance/enum.php +++ /dev/null @@ -1,28 +0,0 @@ - [ - InvoiceStatus::PAID->value => 'Lunas', - InvoiceStatus::PENDING->value => 'Tertunda', - InvoiceStatus::PARTIALLY_PAID->value => 'Dibayar Sebagian', - InvoiceStatus::VOID->value => 'Batal', - ], - 'invoice_type' => [ - InvoiceType::STUDENT_INVOICE->value => 'Faktur Pelanggan', - InvoiceType::VENDOR_BILL->value => 'Tagihan Vendor', - InvoiceType::CREDIT_NOTE->value => 'Nota Kredit', - ], - 'payment_status' => [ - 'pending' => 'Tertunda', - 'success' => 'Berhasil', - 'failed' => 'Gagal', - 'expired' => 'Kedaluwarsa', - ], - 'payment_direction' => [ - 'receipt' => 'Penerimaan', - 'disbursement' => 'Pengeluaran', - ], -]; diff --git a/lang/id/domains/finance/field.php b/lang/id/domains/finance/field.php deleted file mode 100644 index 5e6a98d..0000000 --- a/lang/id/domains/finance/field.php +++ /dev/null @@ -1,39 +0,0 @@ - [ - 'code' => 'Kode Akun', - 'name' => 'Nama Akun', - 'classification' => 'Klasifikasi', - 'status' => 'Status', - 'parent' => 'Turunan Dari', - ], - 'invoice' => [ - 'name' => 'Nama Pembayar', - 'type' => 'Jenis Tagihan', - 'amount' => 'Jumlah Tagihan', - 'client_name' => 'Nama Klien', - 'status' => 'Status', - 'coa_name' => 'Nama Akun (COA)', - 'detail' => [ - // Detail for Student - 'student_name' => 'Nama Mahasiswa', - 'student_id' => 'Nomor Induk Mahasiswa', - 'faculty' => 'Fakultas', - 'study_program' => 'Program Studi', - 'term_entry' => 'Periode Masuk', - 'fee_type' => 'Jenis Biaya', - 'semester' => 'Semester', - - // Vendor Bill - 'vendor_name' => 'Nama Vendor', - 'vendor_id' => 'Nomor Pengenal Vendor', - ], - ], - 'product_mapping' => [ - 'code' => 'Kode Produk', - 'name' => 'Nama Produk', - 'coa_name' => 'Nama Akun (COA)', - 'client_name' => 'Nama Klien', - ], -]; diff --git a/lang/id/domains/finance/seo.php b/lang/id/domains/finance/seo.php deleted file mode 100644 index 2a9613d..0000000 --- a/lang/id/domains/finance/seo.php +++ /dev/null @@ -1,19 +0,0 @@ - [ - 'title' => 'Data Bagan Akun (COA)', - 'description' => 'Kelola bagan akun untuk pencatatan keuangan.', - 'keywords' => 'bagan akun, coa, akuntansi, keuangan', - ], - 'invoice' => [ - 'title' => 'Data Tagihan', - 'description' => 'Kelola tagihan pembayaran mahasiswa.', - 'keywords' => 'tagihan, invoice, pembayaran, keuangan', - ], - 'product_mapping' => [ - 'title' => 'Data Pemetaan Produk', - 'description' => 'Kelola pemetaan produk biaya dengan Bagan Akun (COA).', - 'keywords' => 'pemetaan produk, produk, coa, keuangan', - ], -]; diff --git a/lang/id/domains/identity/dashboard.php b/lang/id/domains/identity/dashboard.php index e48c81b..5d69179 100644 --- a/lang/id/domains/identity/dashboard.php +++ b/lang/id/domains/identity/dashboard.php @@ -11,15 +11,15 @@ return [ ], 'user_verification_rate' => [ 'title' => 'Tingkat Verifikasi', - 'detail' => ':verified terverifikasi / :unverified Belum Terverifikasi', + 'detail' => ':verified Terverifikasi / :unverified Belum Terverifikasi', ], 'user_growth' => [ - 'title' => 'Pertumbuhan', - 'subtitle' => 'Tren bulanan pendaftaran pengguna baru di tahun :year', + 'title' => 'Pertumbuhan Pengguna', + 'subtitle' => 'Tren bulanan pendaftaran pengguna baru pada tahun :year', 'series_name' => 'Registrasi', ], 'role_distribution' => [ - 'title' => 'Peran', - 'subtitle' => 'Rincian pengguna yang ditugaskan untuk setiap peran', + 'title' => 'Distribusi Peran', + 'subtitle' => 'Rincian jumlah pengguna untuk setiap peran', ], ]; diff --git a/lang/id/domains/identity/enum.php b/lang/id/domains/identity/enum.php index 35d3417..6c85b76 100644 --- a/lang/id/domains/identity/enum.php +++ b/lang/id/domains/identity/enum.php @@ -1,17 +1,9 @@ [ - UserStatus::ACTIVE->value => 'Aktif', - UserStatus::INACTIVE->value => 'Nonaktif', - ], - 'role_type' => [ - RoleType::SYSTEM_ADMIN->value => 'Administrator Sistem', - RoleType::ADMIN->value => 'Administrator', - RoleType::USER->value => 'Pengguna', + 'active' => 'Aktif', + 'inactive' => 'Nonaktif', ], 'user_setting_key' => [ 'notification' => 'Notifikasi', diff --git a/lang/id/domains/identity/messages.php b/lang/id/domains/identity/messages.php index 9e49202..7d1148c 100644 --- a/lang/id/domains/identity/messages.php +++ b/lang/id/domains/identity/messages.php @@ -2,14 +2,9 @@ return [ 'success' => [ - 'password_reset_link_sent' => 'Tautan atur ulang kata sandi telah dikirim.', 'user_role_updated' => 'Peran pengguna telah diperbarui.', - 'user_status_updated' => 'Status pengguna telah diperbarui.', ], 'exceptions' => [ - 'failed_to_send_password_reset_link' => 'Gagal mengirim tautan atur ulang kata sandi.', - 'failed_to_update_user_role' => 'Gagal memperbarui peran pengguna.', - 'failed_to_update_user_status' => 'Gagal memperbarui status pengguna.', 'user_already_active' => 'Pengguna ini sudah aktif.', 'user_already_suspended' => 'Pengguna ini sudah dinonaktifkan.', 'user_already_status' => 'Pengguna sudah dalam status ini.', diff --git a/lang/id/domains/identity/notifications.php b/lang/id/domains/identity/notifications.php index ef539a9..0007e52 100644 --- a/lang/id/domains/identity/notifications.php +++ b/lang/id/domains/identity/notifications.php @@ -1,11 +1,6 @@ [ - 'subject' => 'Selamat Datang!', - 'body' => 'Akun Anda sudah siap.', - 'action' => 'Masuk', - ], 'governance' => [ 'user_suspended' => [ 'subject' => 'Akun Ditangguhkan', @@ -26,4 +21,30 @@ return [ 'outro' => 'Jika Anda memiliki pertanyaan, silakan hubungi tim dukungan kami.', ], ], + 'auth' => [ + 'reset_password' => [ + 'subject' => 'Notifikasi Pengaturan Ulang Kata Sandi', + 'intro' => 'Anda menerima email ini karena kami menerima permintaan pengaturan ulang kata sandi untuk akun Anda.', + 'action' => 'Atur Ulang Kata Sandi', + 'outro' => 'Tautan pengaturan ulang kata sandi ini akan kedaluwarsa dalam :count menit. Jika Anda tidak meminta pengaturan ulang kata sandi, tidak ada tindakan lebih lanjut yang diperlukan.', + ], + 'sign_in_activity' => [ + 'subject' => 'Aktivitas Masuk Baru Terdeteksi', + 'greeting' => 'Halo, :name!', + 'intro' => 'Masuk baru terdeteksi pada akun Anda pada :time.', + 'details' => [ + 'ip' => 'Alamat IP: :ip', + 'browser' => 'Browser: :browser', + ], + 'warning' => 'Jika ini bukan Anda, harap segera amankan akun Anda.', + 'action' => 'Buka Dashboard', + 'outro' => 'Terima kasih telah menggunakan aplikasi kami!', + ], + 'verify_email' => [ + 'subject' => 'Verifikasi Alamat Email', + 'intro' => 'Silakan klik tombol di bawah ini untuk memverifikasi alamat email Anda.', + 'action' => 'Verifikasi Alamat Email', + 'outro' => 'Jika Anda tidak membuat akun, tidak ada tindakan lebih lanjut yang diperlukan.', + ], + ], ]; diff --git a/lang/id/domains/identity/pages.php b/lang/id/domains/identity/pages.php index 8da875c..4464dc7 100644 --- a/lang/id/domains/identity/pages.php +++ b/lang/id/domains/identity/pages.php @@ -11,10 +11,7 @@ return [ ], 'confirmation' => [ 'toggle_status' => 'Apakah Anda yakin ingin mengubah status pengguna ini?', - 'status_changed' => 'Status pengguna berhasil diperbarui.', - 'status_unchanged' => 'Status pengguna tidak diubah.', 'send_password_reset' => 'Apakah Anda yakin ingin mengirimkan tautan setel ulang kata sandi kepada pengguna ini?', - 'password_reset_success' => 'Tautan setel ulang kata sandi berhasil dikirim.', ], ], ]; diff --git a/lang/id/domains/identity/seo.php b/lang/id/domains/identity/seo.php index 6ed7c77..638cda2 100644 --- a/lang/id/domains/identity/seo.php +++ b/lang/id/domains/identity/seo.php @@ -6,11 +6,6 @@ return [ 'description' => 'Kelola akun pengguna, peran, dan izin.', 'keywords' => 'manajemen pengguna, pengguna, identitas', ], - 'user_detail' => [ - 'title' => 'Detail dari :name', - 'description' => 'Kelola akun pengguna, peran, dan izin.', - 'keywords' => 'manajemen pengguna, pengguna, identitas', - ], 'role' => [ 'title' => 'Manajemen Peran', 'description' => 'Kelola peran pengguna dan izin.', diff --git a/lang/id/domains/system.php b/lang/id/domains/system.php new file mode 100644 index 0000000..52803c2 --- /dev/null +++ b/lang/id/domains/system.php @@ -0,0 +1,7 @@ + [ + 'something_wrong' => 'Terjadi kesalahan. Silakan coba lagi nanti.', + ], +]; \ No newline at end of file diff --git a/lang/id/domains/system/enum.php b/lang/id/domains/system/enum.php index 88efb3c..4e1fad7 100644 --- a/lang/id/domains/system/enum.php +++ b/lang/id/domains/system/enum.php @@ -1,26 +1,23 @@ [ - LifecycleStatus::ACTIVE->value => 'Aktif', - LifecycleStatus::INACTIVE->value => 'Tidak Aktif', + 'active' => 'Aktif', + 'inactive' => 'Tidak Aktif', ], 'system_setting_key' => [ - SystemSettingKey::WEB_NAME->value => 'Nama Situs Web', - SystemSettingKey::WEB_DESCRIPTION->value => 'Deskripsi Situs Web', - SystemSettingKey::WEB_LOGO->value => 'Logo Situs Web', - SystemSettingKey::WEB_FAVICON->value => 'Favicon Situs Web', - SystemSettingKey::WEB_PHONE->value => 'Telepon Situs Web', - SystemSettingKey::WEB_EMAIL->value => 'Email Situs Web', - SystemSettingKey::WEB_ADDRESS->value => 'Alamat Situs Web', - SystemSettingKey::DEFAULT_LANGUAGE->value => 'Bahasa Default', - SystemSettingKey::TIMEZONE->value => 'Zona Waktu', - SystemSettingKey::GOOGLE_TAG_MANAGER_ID->value => 'ID Google Tag Manager', - SystemSettingKey::GOOGLE_WEBMASTER_ID->value => 'ID Google Webmaster', - SystemSettingKey::CURRENT_TERM->value => 'Periode Saat Ini', + 'web-name' => 'Nama Situs Web', + 'web-description' => 'Deskripsi Situs Web', + 'web-logo' => 'Logo Situs Web', + 'web-favicon' => 'Favicon Situs Web', + 'web-phone' => 'Telepon Situs Web', + 'web-email' => 'Email Situs Web', + 'web-address' => 'Alamat Situs Web', + 'default_language' => 'Bahasa Default', + 'timezone' => 'Zona Waktu', + 'google-tag_manager_id' => 'ID Google Tag Manager', + 'google-webmaster_id' => 'ID Google Webmaster', + 'current_term' => 'Periode Saat Ini', ], 'system_setting_key_options' => [ 'default_language' => [ diff --git a/lang/id/domains/system/field.php b/lang/id/domains/system/field.php index 3b439b2..4ca1a1f 100644 --- a/lang/id/domains/system/field.php +++ b/lang/id/domains/system/field.php @@ -4,23 +4,6 @@ return [ 'backup' => [ 'file' => 'Berkas Cadangan', ], - 'settings' => [ - 'web' => [ - 'name' => 'Nama Web', - 'description' => 'Deskripsi Web', - 'address' => 'Alamat Web', - 'phone' => 'Telepon Web', - 'email' => 'Email Web', - 'logo' => 'Logo Web', - 'favicon' => 'Favicon Web', - ], - 'default_language' => 'Bahasa Default', - 'timezone' => 'Zona Waktu', - 'google' => [ - 'tag_manager_id' => 'ID Google Tag Manager', - 'webmaster_id' => 'ID Google Webmaster', - ], - ], 'audit' => [ 'ip_address' => 'Alamat IP', 'browser' => 'Peramban', diff --git a/lang/id/domains/system/pages.php b/lang/id/domains/system/pages.php index 0423567..24a9a02 100644 --- a/lang/id/domains/system/pages.php +++ b/lang/id/domains/system/pages.php @@ -2,7 +2,6 @@ return [ 'backup' => [ - 'title' => 'Katalog Cadangan Sistem', 'backup_button' => 'Cadangkan', 'upload_button' => 'Unggah Berkas Cadangan', 'upload_modal_title' => 'Unggah Berkas Cadangan', @@ -12,17 +11,10 @@ return [ ], ], 'settings' => [ - 'update_title' => 'Perbarui Pengaturan', 'sections' => [ 'web' => 'Web', 'general' => 'Umum', 'webmaster' => 'Webmaster', ], ], - 'api' => [ - 'sections' => [ - 'academic' => 'Akademik', - 'admission' => 'Penerimaan', - ], - ], ]; diff --git a/lang/id/event.php b/lang/id/event.php index a45d0bb..aab4874 100644 --- a/lang/id/event.php +++ b/lang/id/event.php @@ -4,5 +4,6 @@ return [ 'created' => 'Dibuat', 'updated' => 'Diperbarui', 'deleted' => 'Dihapus', + 'restored' => 'Dipulihkan', 'permissions_synced' => 'Perizinan disinkronisasi', ]; diff --git a/lang/id/permissions.php b/lang/id/permissions.php index f62b500..0aad1aa 100644 --- a/lang/id/permissions.php +++ b/lang/id/permissions.php @@ -2,7 +2,6 @@ return [ 'dashboard' => [ - 'group-name' => 'Dasbor', 'index' => 'Memberikan wewenang untuk mengakses dan melihat ringkasan data pada dasbor utama aplikasi.', 'admin' => 'Memberikan akses khusus ke panel kontrol administrator untuk pemantauan sistem yang komprehensif.', 'user' => 'Memberikan akses ke dasbor pribadi yang berisi informasi yang relevan bagi pengguna umum.', @@ -24,11 +23,106 @@ return [ 'delete' => 'Memberikan wewenang untuk menghapus tingkat peran yang tidak lagi diperlukan oleh sistem.', ], 'system-setting' => [ - 'group-name' => 'Pengaturan Sistem', 'manage' => 'Memberikan akses pengaturan sistem.', + 'api' => 'Memberikan akses pengaturan api sistem.', ], 'system-backup' => [ - 'group-name' => 'Cadangkan Sistem', 'manage' => 'Memberikan akses untuk membuat cadangan dan memulihkan sistem.', ], + 'faculty' => [ + 'group-name' => 'Fakultas', + 'viewAny' => 'Memungkinkan pengguna untuk melihat dan mencari daftar semua fakultas.', + 'view' => 'Memungkinkan pengguna untuk melihat informasi rinci dari fakultas.', + 'create' => 'Memungkinkan untuk membuat fakultas baru.', + 'update' => 'Memungkinkan untuk memperbarui rincian fakultas.', + 'delete' => 'Memungkinkan untuk menghapus fakultas.', + ], + 'study-program' => [ + 'group-name' => 'Program Studi', + 'viewAny' => 'Memungkinkan pengguna untuk melihat dan mencari daftar semua program studi.', + 'view' => 'Memungkinkan pengguna untuk melihat informasi rinci dari program studi.', + 'create' => 'Memungkinkan untuk membuat program studi baru.', + 'update' => 'Memungkinkan untuk memperbarui rincian program studi.', + 'delete' => 'Memungkinkan untuk menghapus program studi.', + ], + 'term' => [ + 'group-name' => 'Tahun Ajaran', + 'viewAny' => 'Memungkinkan pengguna untuk melihat dan mencari daftar semua tahun ajaran.', + 'view' => 'Memungkinkan pengguna untuk melihat informasi rinci dari tahun ajaran.', + 'create' => 'Memungkinkan untuk membuat tahun ajaran baru.', + 'update' => 'Memungkinkan untuk memperbarui rincian tahun ajaran.', + 'delete' => 'Memungkinkan untuk menghapus tahun ajaran.', + ], + 'admission-track' => [ + 'group-name' => 'Jalur Pendaftaran', + 'viewAny' => 'Memungkinkan pengguna untuk melihat dan mencari daftar semua jalur pendaftaran.', + 'view' => 'Memungkinkan pengguna untuk melihat informasi rinci dari jalur pendaftaran.', + 'create' => 'Memungkinkan untuk membuat jalur pendaftaran baru.', + 'update' => 'Memungkinkan untuk memperbarui rincian jalur pendaftaran.', + 'delete' => 'Memungkinkan untuk menghapus jalur pendaftaran.', + ], + 'admission-schedule' => [ + 'group-name' => 'Jadwal Pendaftaran', + 'viewAny' => 'Memungkinkan pengguna untuk melihat dan mencari daftar semua jadwal pendaftaran.', + 'view' => 'Memungkinkan pengguna untuk melihat informasi rinci dari jadwal pendaftaran.', + 'create' => 'Memungkinkan untuk membuat jadwal pendaftaran baru.', + 'update' => 'Memungkinkan untuk memperbarui rincian jadwal pendaftaran.', + 'delete' => 'Memungkinkan untuk menghapus jadwal pendaftaran.', + ], + 'academic-program' => [ + 'group-name' => 'Program Akademik', + 'viewAny' => 'Memungkinkan pengguna untuk melihat dan mencari daftar semua program akademik.', + 'view' => 'Memungkinkan pengguna untuk melihat informasi rinci dari program akademik.', + 'create' => 'Memungkinkan untuk membuat program akademik baru.', + 'update' => 'Memungkinkan untuk memperbarui rincian program akademik.', + 'delete' => 'Memungkinkan untuk menghapus program akademik.', + ], + 'fee-type' => [ + 'group-name' => 'Jenis Biaya', + 'viewAny' => 'Memungkinkan pengguna untuk melihat dan mencari daftar semua jenis biaya.', + 'view' => 'Memungkinkan pengguna untuk melihat informasi rinci dari jenis biaya.', + 'create' => 'Memungkinkan untuk membuat jenis biaya baru.', + 'update' => 'Memungkinkan untuk memperbarui rincian jenis biaya.', + 'delete' => 'Memungkinkan untuk menghapus jenis biaya.', + ], + 'client' => [ + 'group-name' => 'Klien', + 'viewAny' => 'Memungkinkan pengguna untuk melihat dan mencari daftar semua klien.', + 'view' => 'Memungkinkan pengguna untuk melihat informasi rinci dari klien.', + 'create' => 'Memungkinkan untuk membuat klien baru.', + 'update' => 'Memungkinkan untuk memperbarui rincian klien.', + 'delete' => 'Memungkinkan untuk menghapus klien.', + ], + 'coa' => [ + 'group-name' => 'Bagan Akun (COA)', + 'viewAny' => 'Memungkinkan pengguna untuk melihat dan mencari daftar semua COA.', + 'view' => 'Memungkinkan pengguna untuk melihat informasi rinci dari COA.', + 'create' => 'Memungkinkan untuk membuat COA baru.', + 'update' => 'Memungkinkan untuk memperbarui rincian COA.', + 'delete' => 'Memungkinkan untuk menghapus COA.', + ], + 'product' => [ + 'group-name' => 'Produk', + 'viewAny' => 'Memungkinkan pengguna untuk melihat dan mencari daftar semua produk.', + 'view' => 'Memungkinkan pengguna untuk melihat informasi rinci dari produk.', + 'create' => 'Memungkinkan untuk membuat produk baru.', + 'update' => 'Memungkinkan untuk memperbarui rincian produk.', + 'delete' => 'Memungkinkan untuk menghapus produk.', + ], + 'discount' => [ + 'group-name' => 'Diskon', + 'viewAny' => 'Memungkinkan pengguna untuk melihat dan mencari daftar semua diskon.', + 'view' => 'Memungkinkan pengguna untuk melihat informasi rinci dari diskon.', + 'create' => 'Memungkinkan untuk membuat diskon baru.', + 'update' => 'Memungkinkan untuk memperbarui rincian diskon.', + 'delete' => 'Memungkinkan untuk menghapus diskon.', + ], + 'invoice' => [ + 'group-name' => 'Tagihan', + 'viewAny' => 'Memungkinkan pengguna untuk melihat dan mencari daftar semua tagihan.', + 'view' => 'Memungkinkan pengguna untuk melihat informasi rinci dari tagihan.', + 'create' => 'Memungkinkan untuk membuat tagihan baru.', + 'update' => 'Memungkinkan untuk memperbarui rincian tagihan.', + 'delete' => 'Memungkinkan untuk menghapus tagihan.', + ], ]; diff --git a/lang/id/ui/button.php b/lang/id/ui/button.php index 6979779..8f3975b 100644 --- a/lang/id/ui/button.php +++ b/lang/id/ui/button.php @@ -3,21 +3,20 @@ return [ 'save' => 'Simpan Perubahan', 'cancel' => 'Batal', - 'back' => 'Kembali', 'close' => 'Tutup', - 'create' => 'Buat', 'update' => 'Perbarui', 'upload' => 'Unggah', - 'edit' => 'Ubah', + 'edit' => 'Edit', 'delete' => 'Hapus', 'suspend' => 'Tangguhkan', 'view' => 'Lihat', 'log' => 'Log', 'yes' => 'Ya', 'no' => 'Tidak', - 'lookup' => 'Cari...', + 'lookup' => 'Cari', 'logout' => 'Keluar', 'publish' => 'Terbitkan', - 'sync' => 'Sinkronisasi', 'run-sync' => 'Jalankan Sinkronisasi', + 'print' => 'Cetak', + 'back' => 'Kembali', ]; diff --git a/lang/id/ui/common.php b/lang/id/ui/common.php index 940c088..b8a6b93 100644 --- a/lang/id/ui/common.php +++ b/lang/id/ui/common.php @@ -1,5 +1,6 @@ 'Tidak ada data tersedia', 'loading' => 'Memuat...', ]; diff --git a/lang/id/ui/crud.php b/lang/id/ui/crud.php index 4584f06..ddba74a 100644 --- a/lang/id/ui/crud.php +++ b/lang/id/ui/crud.php @@ -9,8 +9,6 @@ return [ 'uploaded' => ':resource telah berhasil diunggah.', ], 'error' => [ - 'forbidden' => 'Anda tidak memiliki izin untuk melakukan tindakan ini.', 'validation_failed' => 'Data yang diberikan tidak valid.', - 'generic' => 'Terjadi kesalahan. Silakan coba lagi.', ], ]; diff --git a/lang/id/ui/greetings.php b/lang/id/ui/greetings.php index cd29d57..0b67a5f 100644 --- a/lang/id/ui/greetings.php +++ b/lang/id/ui/greetings.php @@ -1,6 +1,3 @@ 'Selamat pagi, :name', - 'welcome' => 'Selamat datang kembali di dasbor', -]; +return []; diff --git a/lang/id/ui/label.php b/lang/id/ui/label.php index 8f2afa9..b8a20b3 100644 --- a/lang/id/ui/label.php +++ b/lang/id/ui/label.php @@ -1,11 +1,7 @@ 'ID', 'actions' => 'Aksi', - 'created_at' => 'Dibuat Pada', - 'updated_at' => 'Diperbarui Pada', - 'status' => 'Status', - 'no_data' => 'Tidak Ada Data', + 'no_data' => 'Tidak ada data tersedia', 'fee_type' => 'Jenis Biaya', ]; diff --git a/lang/id/ui/menu.php b/lang/id/ui/menu.php index d501fd1..b468863 100644 --- a/lang/id/ui/menu.php +++ b/lang/id/ui/menu.php @@ -6,7 +6,7 @@ return [ 'academic' => 'Akademik', 'faculties' => 'Fakultas', 'study_programs' => 'Program Studi', - 'terms' => 'Semester', + 'terms' => 'Tahun Ajaran', 'admission' => 'Penerimaan', 'admission_tracks' => 'Jalur Penerimaan', 'academic_programs' => 'Program Akademik', @@ -18,10 +18,7 @@ return [ 'chart_of_accounts' => 'Bagan Akun', 'billing' => 'Penagihan', 'products' => 'Produk', - 'finance' => 'Keuangan', 'invoices' => 'Tagihan', - 'product_mappings' => 'Pemetaan Produk', - 'identity' => 'Manajemen Pengguna', 'users' => 'Pengguna', 'roles' => 'Peran & Izin', 'management' => 'Manajemen', diff --git a/lang/id/ui/notification.php b/lang/id/ui/notification.php index c17b5b4..2129ca6 100644 --- a/lang/id/ui/notification.php +++ b/lang/id/ui/notification.php @@ -3,5 +3,4 @@ return [ 'empty' => 'Tidak ada pemberitahuan', 'read_all' => 'Baca semua pemberitahuan', - 'unread' => 'pesan belum dibaca', ]; diff --git a/resources/scss/app.scss b/resources/scss/app.scss index 893f453..7342dd6 100644 --- a/resources/scss/app.scss +++ b/resources/scss/app.scss @@ -2,6 +2,30 @@ $enable-deprecation-messages: false ); +:root { + scrollbar-color: var(--cui-primary-bg-subtle); + scrollbar-width: thin; +} + +/* Legacy WebKit approach (Older Chrome, Safari, and Opera browsers) */ +html::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +html::-webkit-scrollbar-track { + background: var(--cui-secondary); +} + +html::-webkit-scrollbar-thumb { + background: var(--cui-primary-bg-subtle); + border-radius: 5px; +} + +html::-webkit-scrollbar-thumb:hover { + background: var(--cui-tertiary-bg); +} + svg { pointer-events: none; } diff --git a/resources/views/components/layouts/nav/sidebar.blade.php b/resources/views/components/layouts/nav/sidebar.blade.php index afda060..9f9058c 100644 --- a/resources/views/components/layouts/nav/sidebar.blade.php +++ b/resources/views/components/layouts/nav/sidebar.blade.php @@ -81,7 +81,7 @@ {{ __('ui/menu.accounting') }} @@ -89,10 +89,16 @@ {{ __('ui/menu.billing') }} + {{ __('ui/menu.management') }} put('locale', $locale); app()->setLocale($locale); $this->redirect(request()->header('Referer') ?? route('dashboard'), navigate: true); diff --git a/resources/views/pages/accounting/chartofaccounts/⚡form-modal/form-modal.php b/resources/views/pages/accounting/chartofaccounts/⚡form-modal/form-modal.php index 2654823..bf462a2 100644 --- a/resources/views/pages/accounting/chartofaccounts/⚡form-modal/form-modal.php +++ b/resources/views/pages/accounting/chartofaccounts/⚡form-modal/form-modal.php @@ -5,6 +5,7 @@ use App\Domains\Accounting\Actions\LedgerConfiguration\RegisterChartOfAccount; use App\Domains\Accounting\DTOs\LedgerConfiguration\AdjustAccountClassificationDTO; use App\Domains\Accounting\DTOs\LedgerConfiguration\RegisterChartOfAccountDTO; use App\Domains\Accounting\Models\ChartOfAccount; +use App\Livewire\Concerns\HasModelFill; use App\Livewire\Concerns\WithModal; use App\Livewire\Concerns\WithToast; use App\Livewire\Forms\Accounting\ChartOfAccountForm; diff --git a/resources/views/pages/admission/admission-schedule/⚡detail-view/detail-view.blade.php b/resources/views/pages/admission/admission-schedule/⚡detail-view/detail-view.blade.php index 2bc3403..7e9d101 100644 --- a/resources/views/pages/admission/admission-schedule/⚡detail-view/detail-view.blade.php +++ b/resources/views/pages/admission/admission-schedule/⚡detail-view/detail-view.blade.php @@ -36,7 +36,7 @@ {{ __('domains/admission/field.admission_schedule.date_start') }} {{ $this->admissionSchedule->date_start->format('d M Y') }} - +
{{ __('domains/admission/field.admission_schedule.date_end') }} {{ $this->admissionSchedule->date_end->format('d M Y') }} @@ -54,7 +54,7 @@ {{ __('domains/admission/field.admission_schedule.re_registration_date_start') }} {{ $this->admissionSchedule->re_registration_date_start->format('d M Y') }}
- +
{{ __('domains/admission/field.admission_schedule.re_registration_date_end') }} {{ $this->admissionSchedule->re_registration_date_end->format('d M Y') }} @@ -69,16 +69,18 @@
- + @foreach ($this->studyPrograms as $study) - + {{ $study->name }} @@ -86,18 +88,22 @@
- +
- @endforeach @@ -106,7 +112,8 @@ @foreach ($this->academicPrograms as $program) - @foreach ($this->feeTypes as $fee) @@ -114,14 +121,14 @@ @php($feeRate = isset($this->feeRates[$lookupKey]) ? $this->feeRates[$lookupKey] : new Money(0)) @endforeach diff --git a/resources/views/pages/admission/admission-schedule/⚡detail-view/detail-view.php b/resources/views/pages/admission/admission-schedule/⚡detail-view/detail-view.php index a9cb0a9..10f4f65 100644 --- a/resources/views/pages/admission/admission-schedule/⚡detail-view/detail-view.php +++ b/resources/views/pages/admission/admission-schedule/⚡detail-view/detail-view.php @@ -20,7 +20,7 @@ use Livewire\Attributes\Locked; use Livewire\Component; new #[Layout('components.layouts.app')] -#[Seo(title: 'domains/admission/seo.admission_schedule_detail.title', description: 'domains/admission/seo.admission_schedule_detail.description', keywords: 'domains/admission/seo.admission_schedule-detail.keywords')] +#[Seo(title: 'domains/admission/seo.admission_schedule_detail.title', description: 'domains/admission/seo.admission_schedule_detail.description', keywords: 'domains/admission/seo.admission_schedule_detail.keywords')] #[LayoutData(header: 'domains/admission/seo.admission_schedule.title', breadcrumbs: ['ui/menu.dashboard' => 'dashboard', 'domains/admission/seo.admission_schedule.title' => 'admission-schedules.index', '{admissionTrack.name} {term.name}' => ''], context: 'admissionSchedule')] class extends Component { @@ -89,7 +89,7 @@ class extends Component $amount = $this->feeRates[$lookupKey] ?? '0'; if ($amount instanceof Money) { - $amount = $amount->toFloat(); + $amount = $amount->toInt(); } if ($amount !== '0' && $amount !== 0 && ! empty($amount)) { @@ -124,7 +124,7 @@ class extends Component return 'success'; } - if ($amount instanceof Money && $amount->toFloat() != 0) { + if ($amount instanceof Money && $amount->toInt() != 0) { return 'success'; } diff --git a/resources/views/pages/auth/⚡verify-email/verify-email.php b/resources/views/pages/auth/⚡verify-email/verify-email.php index 42f8c62..abbdc25 100644 --- a/resources/views/pages/auth/⚡verify-email/verify-email.php +++ b/resources/views/pages/auth/⚡verify-email/verify-email.php @@ -1,47 +1,1260 @@ 'domains/auth/pages.verify_email.header'])] -#[Seo(title: 'domains/auth/seo.verify_email.title', description: 'domains/auth/seo.verify_email.description', keywords: 'domains/auth/seo.verify_email.keywords')] -class extends Component +namespace App\Console\Commands; + +use App\Console\Commands\Contracts\AttributeScanHandler; +use App\Console\Commands\Handlers\GenericSeoAttributeHandler; +use App\Console\Commands\Handlers\LayoutAttributeHandler; +use App\Console\Commands\Handlers\LayoutDataAttributeHandler; +use Illuminate\Console\Command; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Str; +use Symfony\Component\Finder\Finder; + +class CheckMissingTranslations extends Command { - use HasSeoAttributes, WithToast; + protected $signature = 'lang:check + {locale=en : The locale to check against} + {--skip=* : Additional paths, files, or globs to exclude} + {--ai : Output a structured prompt for AI to generate missing keys} + {--unused : Check for defined translation keys that are never used in codebase} + {--no-implicit : Disable heuristic detection of key-like strings in Mail/Notification classes} + {--fix : Add missing keys to the language files using placeholder values} + {--purge : Remove unused keys from the language files (implies --unused)} + {--dry-run : Preview what --fix or --purge would change without writing anything} + {--force : Do not ask for confirmation before writing files} + {--mark= : Prefix for placeholder values created by --fix, for example TODO} + {--all-locales : With --purge, purge every locale instead of only the given one} + {--include-json : With --purge, also remove unused JSON keys (risky, strings used by vendor packages are not detected)}'; + + protected $description = 'Check for translation keys used in the application that are missing or defined keys that are unused'; + + private const WILDCARD = "\x1A"; + + private const CALL_PATTERN = <<<'REGEX' +~ +(?: + (?])(?:trans\(\)|app\(\s*['"]translator['"]\s*\))\s*->\s*(?:get|choice|has) + | (?:])(?:__|trans_choice|trans) + | @(?:lang|choice) + | (?])Lang::(?:get|choice|has|hasForLocale|getFromJson) +) +\s*\(\s*(?:key\s*:\s*)? +~x +REGEX; + + private const LITERAL_PATTERN = '/(?|\'((?:[^\'\\\\\r\n]|\\\\.)*)\'|"((?:[^"\\\\\r\n]|\\\\.)*)")/'; + + private const KEY_SHAPE = '/^[A-Za-z0-9_\-]+(?:\/[A-Za-z0-9_\-]+)*(?:\.[A-Za-z0-9_\-]+)+$/'; + + protected array $scanPaths = [ + 'app', + 'routes', + 'config', + 'database', + 'resources/views', + ]; + + protected array $defaultExclusions = [ + 'vendor', + 'storage', + 'bootstrap/cache', + 'node_modules', + 'resources/views/vendor', + '.git', + ]; + + protected array $implicitKeySegments = [ + 'Notifications', + 'Notification', + 'Mail', + 'Mails', + 'Mailables', + ]; + + protected array $permissionKeySuffixes = [ + 'group-name', + 'viewAny', + 'view', + 'create', + 'update', + 'delete', + ]; + + protected array $ignoredKeys = ['key', 'user', 'admissionSchedule']; + + protected array $ignoredUnusedFiles = [ + 'validation', + 'auth', + 'pagination', + 'passwords', + ]; + + protected array $literalKeys = []; + + protected int $scannedFiles = 0; + + /** @var array */ + protected array $attributeHandlers = []; + + public function __construct() + { + parent::__construct(); + + $this->registerAttributeHandler(new LayoutDataAttributeHandler()); + $this->registerAttributeHandler(new LayoutAttributeHandler()); + $this->registerAttributeHandler(new GenericSeoAttributeHandler('Seo')); + $this->registerAttributeHandler(new GenericSeoAttributeHandler('Title')); + } + + public function registerAttributeHandler(AttributeScanHandler $handler): void + { + $this->attributeHandlers[$handler->attributeClass()] = $handler; + } + + public function handle(): int + { + $locale = $this->argument('locale'); + $purge = (bool) $this->option('purge'); + $fix = (bool) $this->option('fix'); + + if ($purge && $fix) { + $this->error('--fix and --purge cannot be combined. Run them separately.'); + + return Command::FAILURE; + } + + if (($this->option('all-locales') || $this->option('include-json')) && ! $purge) { + $this->error('--all-locales and --include-json can only be used together with --purge.'); + + return Command::FAILURE; + } + + $this->info("Checking translation keys against locale: [{$locale}]..."); + + $langData = $this->loadLanguageData($locale); + $scan = $this->scanCodebase($langData); + + $this->line("Scanned {$this->scannedFiles} file(s), found " . count($scan['keys']) . ' static key(s) and ' . count($scan['patterns']) . ' dynamic pattern(s).'); + $this->newLine(); + + if ($this->option('unused') || $purge) { + return $this->checkUnusedKeys($locale, $langData, $scan); + } + + $missingKeys = []; + + foreach ($scan['keys'] as $key => $occurrences) { + if (! $this->hasKey($langData, $key)) { + $missingKeys[$key] = $occurrences; + } + } + + if (empty($missingKeys)) { + $this->info('All translation keys are properly indexed!'); + + return Command::SUCCESS; + } + + $this->error('Found ' . count($missingKeys) . ' missing translation key(s):'); + $this->newLine(); + + $tableData = []; + foreach ($missingKeys as $key => $occurrences) { + foreach ($occurrences as $occ) { + $tableData[] = [$key, $occ['file'] . ':' . $occ['line']]; + } + } + + $this->table(['Missing Key', 'Used In'], $tableData); + + if ($fix) { + return $this->fixMissingKeys($locale, $langData, $missingKeys); + } + + if ($this->option('ai')) { + $this->outputAiPrompt($locale, $missingKeys); + } else { + $this->newLine(); + $this->comment('Tip: Run with --ai to generate a copyable prompt for AI to fix these keys:'); + $this->line(" php artisan lang:check {$locale} --ai"); + $this->comment('Or add placeholders to the language files (preview first with --dry-run):'); + $this->line(" php artisan lang:check {$locale} --fix --dry-run"); + } + + return Command::FAILURE; + } + + private function checkUnusedKeys(string $locale, array $langData, array $scan): int + { + $unusedKeys = $this->findUnusedKeys($langData, $scan); + + if (empty($unusedKeys)) { + $this->info('No unused translation keys found! All defined keys are actively used.'); + + return Command::SUCCESS; + } + + $this->warn('Found ' . count($unusedKeys) . ' unused translation key(s) in language files:'); + $this->newLine(); + + $this->table(['Unused Defined Key'], array_map(fn (string $key) => [$key], $unusedKeys)); + + if ($this->option('purge')) { + return $this->purgeUnusedKeys($locale, $langData, $scan, $unusedKeys); + } + + $this->newLine(); + $this->comment('Tip: remove them from the language files (preview first with --dry-run):'); + $this->line(" php artisan lang:check {$locale} --purge --dry-run"); + + return Command::FAILURE; + } + + private function findUnusedKeys(array $langData, array $scan): array + { + $allDefinedKeys = $this->flattenLangData($langData); + $unusedKeys = []; + + $usedKeys = []; + $markUsed = function (string $key) use (&$usedKeys): void { + $usedKeys[$key] = true; + $usedKeys[str_replace('/', '.', $key)] = true; + }; + + foreach (array_keys($scan['keys']) as $key) { + $markUsed($key); + } + + foreach (array_keys($this->literalKeys) as $key) { + $markUsed($key); + } + + $patterns = []; + foreach (array_keys($scan['patterns']) as $pattern) { + $patterns[] = $pattern; + $patterns[] = str_replace('/', '.', $pattern); + } + $patterns = array_unique($patterns); + + foreach ($allDefinedKeys as $definedKey) { + if ($this->isIgnoredUnusedKey($definedKey)) { + continue; + } + + $normalizedDefinedKey = str_replace('/', '.', $definedKey); + + if (isset($usedKeys[$definedKey]) || isset($usedKeys[$normalizedDefinedKey])) { + continue; + } + + $segments = explode('.', $definedKey); + for ($length = count($segments) - 1; $length >= 2; $length--) { + if (isset($usedKeys[implode('.', array_slice($segments, 0, $length))])) { + continue 2; + } + } + + foreach ($patterns as $pattern) { + if (Str::is($pattern, $definedKey) || Str::is($pattern, $normalizedDefinedKey)) { + continue 2; + } + } + + $unusedKeys[] = $definedKey; + } + + return $unusedKeys; + } + + private function isIgnoredUnusedKey(string $key): bool + { + foreach ($this->ignoredUnusedFiles as $ignored) { + if ( + str_starts_with($key, "{$ignored}.") + || str_contains($key, "/{$ignored}.") + || str_contains($key, ".{$ignored}.") + ) { + return true; + } + } + + return false; + } + + private function flattenLangData(array $langData): array + { + $flattened = []; + + foreach ($langData as $prefix => $data) { + if ($prefix === '__json') { + foreach (array_keys($data) as $jsonKey) { + $flattened[] = (string) $jsonKey; + } + + continue; + } + + array_push($flattened, ...$this->flattenArray($data, (string) $prefix)); + } + + return $flattened; + } + + private function flattenArray(array $data, string $prefix): array + { + $keys = []; + + foreach ($data as $key => $value) { + $path = "{$prefix}.{$key}"; + + if (is_array($value) && $value !== [] && array_keys($value) !== range(0, count($value) - 1)) { + array_push($keys, ...$this->flattenArray($value, $path)); + } else { + $keys[] = $path; + } + } + + return $keys; + } + + private function scanCodebase(array $langData): array + { + $keys = []; + $patterns = []; + $this->literalKeys = []; + $this->scannedFiles = 0; + + $cliSkips = array_filter((array) ($this->option('skip') ?? [])); + $basePath = rtrim(str_replace('\\', '/', base_path()), '/'); + $selfPath = str_replace('\\', '/', __FILE__); + + foreach ($this->scanPaths as $path) { + $dir = base_path($path); + + if (! File::isDirectory($dir)) { + continue; + } + + $finder = Finder::create()->files()->in($dir)->name('*.php'); + + foreach ($finder as $file) { + $absolutePath = str_replace('\\', '/', $file->getPathname()); + + if ($absolutePath === $selfPath) { + continue; + } + + $relativePath = ltrim(substr($absolutePath, strlen($basePath)), '/'); + + if ($this->isExcluded($relativePath, $cliSkips)) { + continue; + } + + $this->scannedFiles++; + $this->scanFile($file->getContents(), $relativePath, $langData, $keys, $patterns); + } + } + + return ['keys' => $keys, 'patterns' => $patterns]; + } + + private function isExcluded(string $relativePath, array $cliSkips): bool + { + foreach ($this->defaultExclusions as $excluded) { + $excluded = trim(str_replace('\\', '/', $excluded), '/'); + + if ($relativePath === $excluded || str_starts_with($relativePath, $excluded . '/')) { + return true; + } + } + + foreach ($cliSkips as $skip) { + $skip = trim(str_replace('\\', '/', (string) $skip), '/'); + + if ($skip === '') { + continue; + } + + if (str_contains($skip, '*')) { + if (Str::is($skip, $relativePath) || Str::is('*/' . $skip, $relativePath)) { + return true; + } + + continue; + } + + if ( + $relativePath === $skip + || str_starts_with($relativePath, $skip . '/') + || str_contains($relativePath, '/' . $skip . '/') + || str_ends_with($relativePath, '/' . $skip) + ) { + return true; + } + } + + return false; + } + + private function scanFile(string $content, string $file, array $langData, array &$keys, array &$patterns): void + { + $content = $this->stripComments($content); + + $this->scanTranslationCalls($content, $file, $keys, $patterns); + $this->scanAttributes($content, $file, $keys); + $this->scanLiteralKeys($content, $file, $langData, $keys); + + if (str_contains($content, 'InteractsWithLabels') && str_contains($content, 'enum ')) { + foreach ($this->extractEnumTranslationKeys($content) as $info) { + $this->addOccurrence($keys, $info['key'], $file, $info['line']); + } + } + + if (str_contains($content, 'function permissions(')) { + foreach ($this->extractPermissionTranslationKeys($content) as $info) { + if ($info['dynamic'] ?? false) { + $this->addOccurrence($patterns, $info['key'], $file, $info['line']); + } else { + $this->addOccurrence($keys, $info['key'], $file, $info['line']); + } + } + } + } + + private function scanTranslationCalls(string $content, string $file, array &$keys, array &$patterns): void + { + if (! preg_match_all(self::CALL_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE)) { + return; + } + + foreach ($matches[0] as [$text, $offset]) { + $line = $this->lineAt($content, $offset); + + foreach ($this->parseFirstArgument($content, $offset + strlen($text)) as $result) { + if ($result['dynamic']) { + $this->addOccurrence($patterns, $result['value'], $file, $line); + + continue; + } + + if (! $this->isInvalidKey($result['value'])) { + $this->addOccurrence($keys, $result['value'], $file, $line); + } + } + } + } + + private function parseFirstArgument(string $content, int $pos): array + { + $len = strlen($content); + $operands = []; + $buffer = ''; + $depth = 0; + + for ($i = $pos; $i < $len; $i++) { + $char = $content[$i]; + + if ($char === "'" || $char === '"') { + $start = $i; + + for ($i++; $i < $len && $content[$i] !== $char; $i++) { + if ($content[$i] === '\\') { + $i++; + } + } + + $buffer .= substr($content, $start, $i - $start + 1); + + continue; + } + + if ($char === '(' || $char === '[' || $char === '{') { + $depth++; + } elseif ($char === ')' || $char === ']' || $char === '}') { + if ($depth === 0) { + break; + } + $depth--; + } elseif ($depth === 0 && $char === ',') { + break; + } elseif ($depth === 0 && $char === '.') { + $prev = $content[$i - 1] ?? ''; + $next = $content[$i + 1] ?? ''; + + $isSpread = $prev === '.' || $next === '.'; + $isDecimal = ctype_digit($prev) && ctype_digit($next); + + if (! $isSpread && ! $isDecimal) { + $operands[] = trim($buffer); + $buffer = ''; + + continue; + } + } + + $buffer .= $char; + } + + $operands[] = trim($buffer); + + if ($operands[0] === '') { + return []; + } + + $value = implode('', array_map(fn (string $op) => $this->decodeOperand($op), $operands)); + $value = preg_replace('/' . self::WILDCARD . '+/', self::WILDCARD, $value); + + if ($value === self::WILDCARD) { + $results = []; + + foreach ($this->extractLiterals(implode(' ', $operands)) as [$literal]) { + if (preg_match(self::KEY_SHAPE, $literal)) { + $results[] = ['value' => $literal, 'dynamic' => false]; + } + } + + return $results; + } + + if (str_contains($value, self::WILDCARD)) { + return [['value' => str_replace(self::WILDCARD, '*', $value), 'dynamic' => true]]; + } + + return [['value' => $value, 'dynamic' => false]]; + } + + private function decodeOperand(string $operand): string + { + if (preg_match('/^\'((?:[^\'\\\\]|\\\\.)*)\'$/s', $operand, $m)) { + return str_replace(["\\'", '\\\\'], ["'", '\\'], $m[1]); + } + + if (preg_match('/^"((?:[^"\\\\]|\\\\.)*)"$/s', $operand, $m)) { + $inner = preg_replace( + '/\{\$(?:[^{}]|\{[^{}]*\})*\}|(?\w+|\[[^\]]*\])*/', + self::WILDCARD, + $m[1] + ); + + return stripcslashes($inner); + } + + return self::WILDCARD; + } + + private function scanAttributes(string $content, string $file, array &$keys): void + { + if (empty($this->attributeHandlers)) { + return; + } + + $classes = implode('|', array_map('preg_quote', array_keys($this->attributeHandlers))); + $pattern = '/#\[(' . $classes . ')\s*\(((?:[^)]|\n)*?)\)\]/'; + + if (! preg_match_all($pattern, $content, $blocks, PREG_OFFSET_CAPTURE)) { + return; + } + + foreach ($blocks[0] as $index => [$fullBlock, $blockOffset]) { + $attributeName = $blocks[1][$index][0]; + $body = $blocks[2][$index][0]; + + if (! isset($this->attributeHandlers[$attributeName])) { + continue; + } + + $parsedArguments = $this->parseAttributeArguments($body); + $extractedKeys = $this->attributeHandlers[$attributeName]->extractTranslationKeys($parsedArguments); + + foreach ($extractedKeys as $key) { + if (! $this->isInvalidKey($key)) { + $line = $this->lineAt($content, $blockOffset); + $this->addOccurrence($keys, $key, $file, $line); + } + } + } + } + + private function parseAttributeArguments(string $body): array + { + try { + $evaluable = preg_replace('/([a-zA-Z0-9_]+)\s*:/', "'$1' =>", $body); + return eval("return [{$evaluable}];") ?? []; + } catch (\Throwable) { + return []; + } + } + + private function scanLiteralKeys(string $content, string $file, array $langData, array &$keys): void + { + $isImplicitFile = ! $this->option('no-implicit') && $this->isImplicitFile($file); + + foreach ($this->extractLiterals($content) as [$literal, $offset]) { + if (! preg_match(self::KEY_SHAPE, $literal)) { + continue; + } + + $this->literalKeys[$literal] = true; + + if (! $isImplicitFile || ! $this->groupExists($langData, $literal)) { + continue; + } + + $ctxStart = max(0, $offset - 41); + $context = substr($content, $ctxStart, max(0, $offset - 1 - $ctxStart)); + + if (preg_match('/(?:view|markdown|html|text|route|config|queue|connection|disk)\s*(?:\(|:|=>)\s*$/i', $context)) { + continue; + } + + if (! $this->isInvalidKey($literal)) { + $this->addOccurrence($keys, $literal, $file, $this->lineAt($content, $offset)); + } + } + } + + private function isImplicitFile(string $file): bool + { + foreach ($this->implicitKeySegments as $segment) { + if (preg_match('~(?:^|/)' . preg_quote($segment, '~') . '/~', $file)) { + return true; + } + } + + return false; + } + + private function groupExists(array $langData, string $key): bool + { + $group = explode('.', $key, 2)[0]; + + return $group !== '' && $group !== '__json' && isset($langData[$group]); + } + + private function extractLiterals(string $content): array + { + if (! preg_match_all(self::LITERAL_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE)) { + return []; + } + + return $matches[1]; + } + + private function stripComments(string $content): string + { + $blank = static fn (array $m): string => str_repeat("\n", substr_count($m[0], "\n")); + + $content = preg_replace_callback('/\{\{--.*?--\}\}/s', $blank, $content) ?? $content; + $content = preg_replace_callback('~^[ \t]*/\*.*?\*/~ms', $blank, $content) ?? $content; + + return preg_replace('~^[ \t]*//.*$~m', '', $content) ?? $content; + } - public function mount(): void + private function lineAt(string $content, int $offset): int { - $this->checkVerified(); + return substr_count($content, "\n", 0, $offset) + 1; } - public function checkVerified(): void + private function addOccurrence(array &$bucket, string $key, string $file, int $line): void { - if (request()->user()->hasVerifiedEmail()) { - $this->redirectRoute('dashboard'); + $bucket[$key]["{$file}:{$line}"] = ['file' => $file, 'line' => $line]; + } + + private function extractPermissionTranslationKeys(string $content): array + { + $keys = []; + + foreach (explode("\n", $content) as $lineNum => $line) { + if (preg_match('/[\'"]description[\'"]\s*=>\s*[\'"]([^\'"]+)[\'"]/', $line, $matches)) { + if (! $this->isInvalidKey($matches[1])) { + $keys[] = ['key' => $matches[1], 'line' => $lineNum + 1]; + } + } + } + + if (preg_match_all('/generatePolicy\(\s*[\'"]([^\'"]+)[\'"]/', $content, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches[1] as [$modelName, $offset]) { + $line = $this->lineAt($content, $offset); + + foreach ($this->permissionKeySuffixes as $suffix) { + $keys[] = ['key' => "permissions.{$modelName}.{$suffix}", 'line' => $line]; + } + + $keys[] = ['key' => "permissions.{$modelName}.*", 'line' => $line, 'dynamic' => true]; + } + } + + return $keys; + } + + private function extractEnumTranslationKeys(string $content): array + { + if (! preg_match('/namespace\s+([^;]+);/', $content, $namespaceMatch)) { + return []; + } + + if (! preg_match('/enum\s+([^\s:]+)/', $content, $enumMatch)) { + return []; + } + + $fqcn = trim($namespaceMatch[1]) . '\\' . trim($enumMatch[1]); + $enumSlug = Str::snake(trim($enumMatch[1])); + + if (Str::startsWith($fqcn, 'App\\Domains\\')) { + $domain = Str::between($fqcn, 'App\\Domains\\', '\\Enums\\'); + $keyPrefix = 'domains/' . Str::kebab($domain) . "/enum.{$enumSlug}"; + } elseif (Str::startsWith($fqcn, 'App\\UI\\Enums\\')) { + $keyPrefix = "ui/enum.{$enumSlug}"; + } else { + $keyPrefix = "enum.{$enumSlug}"; + } + + $keys = []; + + foreach (explode("\n", $content) as $lineNum => $line) { + if (preg_match('/case\s+[A-Za-z0-9_]+\s*=\s*[\'"]([^\'"]+)[\'"]\s*;/', $line, $caseMatch)) { + $keys[] = ['key' => "{$keyPrefix}.{$caseMatch[1]}", 'line' => $lineNum + 1]; + } + } + + return $keys; + } + + private function loadLanguageData(string $locale): array + { + $langData = []; + $langPath = lang_path($locale); + $jsonPath = lang_path("{$locale}.json"); + + if (File::exists($jsonPath)) { + $langData['__json'] = json_decode(File::get($jsonPath), true) ?? []; + } + + if (File::isDirectory($langPath)) { + foreach (File::allFiles($langPath) as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $relativePath = str_replace('\\', '/', $file->getRelativePathname()); + $content = require $file->getPathname(); + + if (is_array($content)) { + $langData[substr($relativePath, 0, -4)] = $content; + } + } + } + + return $langData; + } + + private function hasKey(array $langData, string $key): bool + { + if (isset($langData['__json']) && array_key_exists($key, $langData['__json'])) { + return true; + } + + if (! str_contains($key, '.')) { + return false; + } + + foreach ($langData as $prefix => $data) { + if ($prefix === '__json') { + continue; + } + + if (str_starts_with($key, $prefix . '.')) { + $item = substr($key, strlen($prefix) + 1); + + if (Arr::has($data, $item)) { + return true; + } + } + + $normalizedPrefix = str_replace('/', '.', $prefix); + $normalizedKey = str_replace('/', '.', $key); + + if (str_starts_with($normalizedKey, $normalizedPrefix . '.')) { + $item = substr($normalizedKey, strlen($normalizedPrefix) + 1); + + if (Arr::has($data, $item)) { + return true; + } + } + } + + return false; + } + + private function isInvalidKey(string $key): bool + { + return $key === '' + || str_contains($key, '::') + || preg_match('/^\$\w+$/', $key) === 1 + || preg_match('/^\{[A-Za-z0-9_]+\}$/', $key) === 1 + || str_contains($key, '...') + || str_contains($key, ')]') + || in_array($key, $this->ignoredKeys, true); + } + + private function fixMissingKeys(string $locale, array $langData, array $missingKeys): int + { + ksort($missingKeys); + + $mark = (string) ($this->option('mark') ?? ''); + $mark = $mark === '' ? '' : rtrim($mark) . ' '; + + $data = $langData; + $dirty = []; + $added = []; + $conflicts = []; + + foreach (array_keys($missingKeys) as $key) { + [$bucket, $item] = $this->resolveTarget($key); + + if ($bucket === '__json') { + $value = $mark . $key; + $data['__json'][$key] = $value; + } else { + $value = $mark . $this->humanize($item); + $data[$bucket] ??= []; + + if (! $this->setPath($data[$bucket], $item, $value)) { + $conflicts[] = $key; + + continue; + } + } + + $dirty[$bucket] = true; + $added[$key] = ['bucket' => $bucket, 'value' => $value]; + } + + $skippedFiles = []; + foreach (array_keys($dirty) as $bucket) { + if ($bucket === '__json') { + continue; + } + + $path = lang_path("{$locale}/{$bucket}.php"); + + if (! $this->isSafelyRewritable($path)) { + $skippedFiles[$bucket] = $path; + unset($dirty[$bucket]); + } + } + + $rows = []; + foreach ($added as $key => $info) { + if (isset($skippedFiles[$info['bucket']])) { + continue; + } + + $rows[] = [$key, $info['value'], $this->langFileLabel($locale, $info['bucket'])]; + } + + $this->newLine(); + + if (! empty($rows)) { + $this->info('The following keys will be added:'); + $this->table(['Key', 'Placeholder Value', 'File'], $rows); + } + + foreach ($conflicts as $key) { + $this->error("Cannot add [{$key}]: a parent segment already holds a plain string value. Resolve it manually."); + } + + foreach ($skippedFiles as $bucket => $path) { + $this->warn('Skipped ' . $this->relativePath($path) . ': it contains executable PHP (closures, constants, function calls) and cannot be rewritten safely. Add its keys manually.'); + } + + if (empty($dirty)) { + return Command::FAILURE; + } + + if (! $this->shouldWrite('Write ' . count($rows) . ' key(s) into ' . count($dirty) . ' file(s)? Comments and custom formatting in those files will be lost.')) { + return Command::FAILURE; + } + + foreach (array_keys($dirty) as $bucket) { + $this->writeLangFile($locale, $bucket, $data[$bucket]); + } + + $this->info('Added ' . count($rows) . ' placeholder key(s) to ' . count($dirty) . ' file(s). Replace the placeholder values with real translations.'); + + return (empty($conflicts) && empty($skippedFiles)) ? Command::SUCCESS : Command::FAILURE; + } + + private function purgeUnusedKeys(string $locale, array $langData, array $scan, array $unusedKeys): int + { + $includeJson = (bool) $this->option('include-json'); + $locales = $this->option('all-locales') ? $this->discoverLocales() : [$locale]; + + $plans = []; + $skippedJson = 0; + $skippedFiles = []; + + foreach ($locales as $loc) { + $data = $loc === $locale ? $langData : $this->loadLanguageData($loc); + $unused = $loc === $locale ? $unusedKeys : $this->findUnusedKeys($data, $scan); + $removed = []; + + foreach ($unused as $key) { + $bucket = $this->locateKey($data, $key); + + if ($bucket === null) { + continue; + } + + if ($bucket === '__json') { + if (! $includeJson) { + $skippedJson++; + + continue; + } + + unset($data['__json'][$key]); + } else { + $this->forgetPath($data[$bucket], substr($key, strlen($bucket) + 1)); + } + + $removed[$bucket][] = $key; + } + + foreach (array_keys($removed) as $bucket) { + if ($bucket === '__json') { + continue; + } + + $path = lang_path("{$loc}/{$bucket}.php"); + + if (! $this->isSafelyRewritable($path)) { + $skippedFiles[$path] = true; + unset($removed[$bucket]); + } + } + + if (! empty($removed)) { + $plans[$loc] = ['data' => $data, 'removed' => $removed]; + } + } + + $this->newLine(); + + foreach (array_keys($skippedFiles) as $path) { + $this->warn('Skipped ' . $this->relativePath($path) . ': it contains executable PHP (closures, constants, function calls) and cannot be rewritten safely. Edit it manually.'); + } + + if ($skippedJson > 0) { + $this->comment("{$skippedJson} unused JSON key(s) were left untouched. Use --include-json to remove them as well."); + } + + if (empty($plans)) { + $this->warn('Nothing can be purged automatically.'); + + return Command::FAILURE; + } + + $rows = []; + $total = 0; + $fileCount = 0; + + foreach ($plans as $loc => $plan) { + foreach ($plan['removed'] as $bucket => $keys) { + $rows[] = [$loc, $this->langFileLabel($loc, $bucket), count($keys)]; + $total += count($keys); + $fileCount++; + } + } + + $this->info('The following keys will be removed:'); + $this->table(['Locale', 'File', 'Keys to Remove'], $rows); + + if (! $this->shouldWrite("Remove {$total} key(s) from {$fileCount} file(s)? Comments and custom formatting in those files will be lost.")) { + return Command::FAILURE; + } + + foreach ($plans as $loc => $plan) { + foreach (array_keys($plan['removed']) as $bucket) { + $this->writeLangFile($loc, $bucket, $plan['data'][$bucket] ?? []); + } + } + + $this->info("Removed {$total} unused key(s) from {$fileCount} file(s)."); + + return empty($skippedFiles) ? Command::SUCCESS : Command::FAILURE; + } + + private function shouldWrite(string $question): bool + { + if ($this->option('dry-run')) { + $this->comment('Dry run: no files were written.'); + + return false; + } + + if ($this->option('force')) { + return true; + } + + if (! $this->confirm($question, false)) { + $this->comment('Aborted. No files were written.'); + + return false; + } + + return true; + } + + private function resolveTarget(string $key): array + { + if (preg_match(self::KEY_SHAPE, $key)) { + [$group, $item] = explode('.', $key, 2); + + return [$group, $item]; + } + + return ['__json', $key]; + } + + private function locateKey(array $data, string $key): ?string + { + if (isset($data['__json']) && array_key_exists($key, $data['__json'])) { + return '__json'; + } + + $group = explode('.', $key, 2)[0]; + + return ($group !== '__json' && isset($data[$group])) ? $group : null; + } + + private function humanize(string $item): string + { + $segments = explode('.', $item); + $last = (string) end($segments); + + $words = preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', $last); + $words = trim(str_replace(['-', '_'], ' ', $words)); + + return ucfirst(mb_strtolower($words)); + } + + private function setPath(array &$data, string $path, string $value): bool + { + $segments = explode('.', $path); + $last = count($segments) - 1; + $ref = &$data; + + foreach ($segments as $index => $segment) { + if ($index === $last) { + if (array_key_exists($segment, $ref)) { + return false; + } + + $ref[$segment] = $value; + + return true; + } + + if (! array_key_exists($segment, $ref)) { + $ref[$segment] = []; + } + + if (! is_array($ref[$segment])) { + return false; + } + + $ref = &$ref[$segment]; + } + + return false; + } + + private function forgetPath(array &$data, string $path): void + { + if (array_key_exists($path, $data)) { + unset($data[$path]); return; } + + $this->forgetSegments($data, explode('.', $path)); } - public function resendEmail(ResendVerificationEmail $action): void + private function forgetSegments(array &$data, array $segments): void { - $this->checkVerified(); + $first = array_shift($segments); + + if (! array_key_exists($first, $data)) { + return; + } + + if ($segments === []) { + unset($data[$first]); + + return; + } + + if (! is_array($data[$first])) { + return; + } - // The Gateway resolves the User from the session and passes it - // explicitly — the domain action has no HTTP/request dependency. - $action->execute(Auth::user()); + $this->forgetSegments($data[$first], $segments); - $this->success(__('domains/auth/pages.verify_email.resend_link')); + if ($data[$first] === []) { + unset($data[$first]); + } + } + + private function writeLangFile(string $locale, string $bucket, array $data): void + { + if ($bucket === '__json') { + $json = json_encode( + $data === [] ? new \stdClass : $data, + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_FORCE_OBJECT + ); + + File::put(lang_path("{$locale}.json"), $json . "\n"); + + return; + } + + $path = lang_path("{$locale}/{$bucket}.php"); + File::ensureDirectoryExists(dirname($path)); + File::put($path, "exportPhpArray($data) . ";\n"); + } + + private function exportPhpArray(array $data, int $depth = 0): string + { + if ($data === []) { + return '[]'; + } + + $indent = str_repeat(' ', $depth + 1); + $isList = array_keys($data) === range(0, count($data) - 1); + $lines = []; + + foreach ($data as $key => $value) { + $keyPart = $isList ? '' : $this->exportScalar($key) . ' => '; + $valuePart = is_array($value) ? $this->exportPhpArray($value, $depth + 1) : $this->exportScalar($value); + + $lines[] = $indent . $keyPart . $valuePart . ','; + } + + return "[\n" . implode("\n", $lines) . "\n" . str_repeat(' ', $depth) . ']'; + } + + private function exportScalar(mixed $value): string + { + return match (true) { + is_string($value) => "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $value) . "'", + is_bool($value) =>$value ? 'true' : 'false', + $value === null => 'null', + default => var_export($value, true), + }; + } + + private function isSafelyRewritable(string $path): bool + { + if (! File::exists($path)) { + return true; + } + + $allowedTokens = [ + T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT, T_RETURN, T_ARRAY, + T_DOUBLE_ARROW, T_CONSTANT_ENCAPSED_STRING, T_LNUMBER, T_DNUMBER, T_DECLARE, + ]; + + foreach (token_get_all(File::get($path)) as$token) { + if (is_string($token)) { + if (! str_contains('[],();-+=', $token)) { + return false; + } + + continue; + } + + [$id, $text] =$token; + + if (in_array($id,$allowedTokens, true)) { + continue; + } + + if ($id === T_STRING && in_array(strtolower($text), ['true', 'false', 'null', 'strict_types'], true)) { + continue; + } + + return false; + } + + return true; + } + + private function discoverLocales(): array + { + $locales = []; + + foreach (File::directories(lang_path()) as $dir) { + if (basename($dir) !== 'vendor') { + $locales[] = basename($dir); + } + } + + foreach (File::glob(lang_path('*.json')) ?: [] as $jsonFile) { + $locales[] = basename($jsonFile, '.json'); + } + + return array_values(array_unique($locales)); } - public function logout(): void + private function langFileLabel(string $locale, string$bucket): string { - Auth::logout(); - $this->redirectRoute('login', navigate: true); + return $this->relativePath( + $bucket === '__json' ? lang_path("{$locale}.json") : lang_path("{$locale}/{$bucket}.php") + ); + } + + private function relativePath(string $path): string + { + $base = rtrim(str_replace('\\', '/', base_path()), '/'); + + return ltrim(str_replace($base, '', str_replace('\\', '/',$path)), '/'); + } + + private function outputAiPrompt(string $locale, array$missingKeys): void + { + $this->newLine();$this->info('==================== AI TRANSLATION PROMPT ===================='); + $this->comment('Copy the block below and paste it into ChatGPT, Claude, Gemini, or Cursor:');$this->newLine(); + + $groupedByDomain = []; + foreach ($missingKeys as$key => $occurrences) {$parts = explode('.', $key);$domain = count($parts) > 1 ? array_shift($parts) : 'general'; + $groupedByDomain[$domain][$key] = array_column($occurrences, 'file'); + } + + $prompt = "I have missing translation keys in my Laravel project for locale [{$locale}].\n"; + $prompt .= "Please generate the corresponding PHP array files or JSON entries for these keys.\n\n"; + $prompt .= "### Expected Structure:\n"; + $prompt .= "- For domain-prefixed keys (e.g. `domains/identity.field.user.name`), create or update `lang/{$locale}/domains/identity.php`.\n"; + $prompt .= "- Keys without a dot (e.g. `You're receiving this email`) belong in `lang/{$locale}.json`.\n"; + $prompt .= "- Use proper nested PHP arrays representing dot-notation keys.\n"; + $prompt .= "- Translate values naturally for target locale [{$locale}].\n\n"; + $prompt .= "### Missing Keys to Index:\n\n"; + + foreach ($groupedByDomain as $domain => $keys) { + $prompt .= "#### Domain/File: {$domain}\n"; + foreach ($keys as $key => $files) { + $uniqueFiles = array_unique($files); + $prompt .= "- `{$key}` (Used in: " . implode(', ', array_slice($uniqueFiles, 0, 2)) . ")\n"; + } + $prompt .= "\n"; + } + + $prompt .= 'Please output the ready-to-use PHP array code for each file.'; + + $this->line($prompt); + $this->newLine(); + $this->info('==============================================================='); } -}; +} diff --git a/resources/views/pages/billing/invoices/index.blade.php b/resources/views/pages/billing/invoices/index.blade.php new file mode 100644 index 0000000..b21e9ea --- /dev/null +++ b/resources/views/pages/billing/invoices/index.blade.php @@ -0,0 +1,18 @@ + + + {{ $dataTable->table() }} + + + + + + + @push('page-scripts') + @vite(['resources/js/plugin/datatables.js', 'resources/js/plugin/select2.js']) + {{ $dataTable->scripts(attributes: ['type' => 'module']) }} + @endpush + diff --git a/resources/views/pages/billing/invoices/⚡create-form-view/create-form-view.blade.php b/resources/views/pages/billing/invoices/⚡create-form-view/create-form-view.blade.php new file mode 100644 index 0000000..e51a997 --- /dev/null +++ b/resources/views/pages/billing/invoices/⚡create-form-view/create-form-view.blade.php @@ -0,0 +1,273 @@ +@use(App\Domains\Billing\Enums\Invoicing\InvoiceType) +@use(App\Domains\System\Support\ValueObjects\Money) + +
+ +
+ +
+
+
+ +
+
+ +
+
+
+
+ + +
+ + +
+ @foreach($this->schema as $schema) + @php($model = $schema->isLive ? 'wire:model.live' : 'wire:model') +
+ + +
+ @endforeach +
+ + + @if($form->type?->schema()->isCannotAddItems()) +
+ +
+ @endif +
+ + + + + @if($form->type?->schema()->isCanAddItems()) + + + @endif + + +
+
+ {{ __('domains/admission/field.academic_program.name') }} - + @foreach ($this->feeTypes as $fee) - - {{ $fee->name }} + + {{ $fee->name }}
+ {{ $program->name }}
+ + + + + + + + + + + + + @forelse($this->form->items ?? [] as $index => $item) + + + + + + + + + + @empty + + + + @endforelse + +
#@lang('domains/billing/field.invoice_item.code')@lang('domains/billing/field.invoice_item.name')@lang('domains/billing/field.invoice_item.quantity')@lang('domains/billing/field.invoice_item.gross_price')@lang('domains/billing/field.invoice_item.discounted_price')@lang('domains/billing/field.invoice_item.net_price')
{{ $index + 1 }} + {{ $item['code'] ?? '-' }} + {{ $item['name'] ?? '-' }}{{ $item['quantity'] ?? 1 }}{{ $item['gross_amount'] ?? new Money(0) }}{{ $item['discount_amount'] ?? new Money(0) }} + {{ $item['net_amount'] ?? new Money(0) }} +
+ +
+ + + +
+

{{ __('ui/common.no_data') }}

+
+
+
+
+ + +
+
+ +
+ +
+ + @lang('domains/billing/field.invoice.status') + + + @lang('domains/billing/enum.invoice_status.draft') + +
+ +
+ +
+ + @lang('domains/billing/field.invoice.gross_amount') + + + {{ $form->gross_amount }} + +
+ + +
+ + @lang('domains/billing/field.invoice.discount_amount') + + + -{{ $form->discount_amount }} + +
+ +
+ + +
+ + @lang('domains/billing/field.invoice.net_amount') + + + {{ $form->net_amount }} + +
+
+ + +
+ + + + + + + + +
+
+
+
+
+
+ +@script + +@endscript diff --git a/resources/views/pages/billing/invoices/⚡create-form-view/create-form-view.php b/resources/views/pages/billing/invoices/⚡create-form-view/create-form-view.php new file mode 100644 index 0000000..54664bb --- /dev/null +++ b/resources/views/pages/billing/invoices/⚡create-form-view/create-form-view.php @@ -0,0 +1,100 @@ + 'dashboard', 'resources.invoice' => 'invoices.index', 'domains/billing/seo.create_invoice.title' => ''])] +#[Seo(title: 'domains/billing/seo.create_invoice.title')] +class extends Component +{ + use HasSeoAttributes; + use HasLayoutDataAttributes; + use WithToast; + + public InvoiceForm $form; + + #[Computed] + public function clients(): Collection { + return Client::all(['id', 'name'])->pluck('name', 'id'); + } + + #[Computed] + public function schema(): array + { + return $this->form->type + ?->schema() + ->withContext($this->form->client_detail) + ->getSchema() ?? []; + } + + public function saveDraft(DraftInvoice $createInvoice): void + { + $this->form->validate(); + try { + $createInvoice->execute($this->form->toDTO()); + $this->success(__('domains/billing.messages.invoice.draft_success')); + } catch (\Throwable $th) { + $this->error($th->getMessage()); + } + } + + public function searchStudentAdmission(): void + { + try { + $detail = $this->form->client_detail; + $feeRate = SearchAdmissionSchedulePricing::fetch( + admissionTrack: $detail['admission_track'], + academicProgram: $detail['academic_program'], + studyProgram: $detail['study_program'], + termEntry: $detail['term_entry'], + feeType: $detail['fee_type'] + ); + + $product = SearchProductInfo::fetch(model: $feeRate->getMorphClass(), model_id: $feeRate->id); + $this->form->items = []; + $this->form->addItem( + code: $product->code, + name: $product->name, + quantity: 1, + price: $feeRate->amount, + discount: new Money(0), + product_id: $product->id, + coa_id: $product->chart_of_account_id + ); + $this->form->populatePricing(); + } catch (Exception $exception) { + $this->error($exception->getMessage()); + } + } + + public function createInvoice(IssueInvoice $issueInvoice): void + { + $this->form->validate(); + try { + $issueInvoice->execute($this->form->toDTO()); + $this->success(__('domains/billing.messages.invoice.issue_success')); + sleep(5); + $this->redirect(route('invoices.index')); + } catch (\Throwable $th) { + $this->error($th->getMessage()); + } + } +}; diff --git a/resources/views/pages/billing/invoices/⚡detail-view/detail-view.blade.php b/resources/views/pages/billing/invoices/⚡detail-view/detail-view.blade.php new file mode 100644 index 0000000..ea99ccb --- /dev/null +++ b/resources/views/pages/billing/invoices/⚡detail-view/detail-view.blade.php @@ -0,0 +1,251 @@ +@use(App\Domains\Billing\Enums\Invoicing\InvoiceType) +@use(App\Domains\System\Support\ValueObjects\Money) + +
+ +
+
+
+
+ +
+
+ + +
+
+ + + {{ __('domains/billing/field.invoice.name') }} + + +

+ {{ $this->invoice->name ?? '-' }} +

+
+
+ + +
+ +
+
+
+
+
+ + +
+ + + + +
+ @forelse($this->schema as $schema) +
+
+ + + {{ $schema->attributes['label'] ?? __("domains/billing/field.invoice.{$schema->key}") }} + + + + @if(isset($this->invoice->client_detail[$schema->key.'_label'])) + {{ $this->invoice->client_detail[$schema->key.'_label'] }} + @elseif($this->invoice->client_detail[$schema->key] != '') + {{ $this->invoice->client_detail[$schema->key] }} + @else + - + @endif + +
+
+ @empty + +
+
+ - +
+
+ @endforelse +
+
+ + + +
+ + + + + + + + + + + + + + + + @forelse($this->invoice->items ?? [] as $index => $item) + + + + + + + + + + + + + + + + + + + + + + + @empty + + + + + @endforelse + +
#@lang('domains/billing/field.invoice_item.code')@lang('domains/billing/field.invoice_item.name')@lang('domains/billing/field.invoice_item.quantity')@lang('domains/billing/field.invoice_item.gross_price')@lang('domains/billing/field.invoice_item.discounted_price')@lang('domains/billing/field.invoice_item.net_price')
{{ $index + 1 }} + {{ $item['code'] ?? '-' }} + {{ $item['name'] ?? '-' }}{{ $item['quantity'] ?? 1 }} + {{ $item['gross_amount'] ?? new Money(0) }} + + {{ $item['discount_amount'] ?? new Money(0) }} + + {{ $item['net_amount'] ?? new Money(0) }} +
+
+ + +
+
{{ __('ui/common.no_data') }}
+
+
+
+
+ + +
+
+ +
+ +
+ + @lang('domains/billing/field.invoice.status') + + +
+ + +
+ +
+ + @lang('domains/billing/field.invoice.gross_amount') + + + {{ $this->invoice->gross_amount ?? '-' }} + +
+ + +
+ + @lang('domains/billing/field.invoice.discount_amount') + + + -{{ $this->invoice->discount_amount ?? '-' }} + +
+ +
+ + +
+
+ + @lang('domains/billing/field.invoice.net_amount') + +
+ + {{ $this->invoice->net_amount ?? '-' }} + +
+
+ + +
+ @if($this->invoice->status->isDraft()) + + + @endif + + + + + + +
+
+
+
+
+
diff --git a/resources/views/pages/billing/invoices/⚡detail-view/detail-view.php b/resources/views/pages/billing/invoices/⚡detail-view/detail-view.php new file mode 100644 index 0000000..8e0322a --- /dev/null +++ b/resources/views/pages/billing/invoices/⚡detail-view/detail-view.php @@ -0,0 +1,37 @@ +invoiceId = $id; + } + + #[Computed] + public function invoice(): Invoice + { + return Invoice::with(['items'])->findOrFail($this->invoiceId); + } + + #[Computed] + public function schema(): array + { + return $this->invoice->type->schema()->getSchema(); + } + + public function issueInvoice(UpdateInvoiceStatus $invoiceStatus): void + { + $invoiceStatus->execute($this->invoice, InvoiceStatus::ISSUED); + $this->success(__('domains/billing/messages.invoice_issued')); + } +}; diff --git a/resources/views/pages/billing/products/⚡form-modal/form-modal.php b/resources/views/pages/billing/products/⚡form-modal/form-modal.php index b947dbd..459a72a 100644 --- a/resources/views/pages/billing/products/⚡form-modal/form-modal.php +++ b/resources/views/pages/billing/products/⚡form-modal/form-modal.php @@ -1,7 +1,7 @@ id = $id; - $this->mode = 'update'; $product = Product::findOrFail($id); $this->form->fill([ @@ -52,24 +51,12 @@ new class extends Component ->mapWithKeys(fn ($coa) => [$coa->id => "{$coa->code} - {$coa->name}"]); } - public function save(RegisterProduct $register, ModifyProduct $modify): void + public function save(SetChartOfAccountForProduct $modify): void { $this->form->validate(); - if ($this->mode === 'create') { - $register->execute(new RegisterProductDTO( - name: $this->form->name, - code: $this->form->code, - coaId: $this->form->coa_id, - )); - } elseif ($this->mode === 'update') { - $product = Product::findOrFail($this->id); - $modify->execute($product, new ModifyProductDTO( - name: $this->form->name, - code: $this->form->code, - coaId: $this->form->coa_id, - )); - } + $product = Product::findOrFail($this->id); + $modify->execute($product, $this->form->coa_id); $this->dispatch('hide-product-form-modal'); $this->js("LaravelDataTables['product-table'].ajax.reload(null, false)"); diff --git a/resources/views/pages/identity/users/⚡detail-view/detail-view.php b/resources/views/pages/identity/users/⚡detail-view/detail-view.php index 204c8cf..d5609fd 100644 --- a/resources/views/pages/identity/users/⚡detail-view/detail-view.php +++ b/resources/views/pages/identity/users/⚡detail-view/detail-view.php @@ -1,76 +1,1620 @@ 'dashboard', 'domains/identity/seo.user.title' => 'users.index', '{name}' => ''], context: 'user')] -#[Seo(title: 'domains/identity/seo.user_detail.title', context: 'user')] -class extends Component +namespace App\Console\Commands; + +use Illuminate\Console\Command; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Str; +use Symfony\Component\Finder\Finder; + +class CheckMissingTranslations extends Command { - use HasLayoutDataAttributes; - use HasSeoAttributes; - use WithToast; + protected $signature = 'lang:check + {locale=en : The locale to check against} + {--skip=* : Additional paths, files, or globs to exclude} + {--ai : Output a structured prompt for AI to generate missing keys} + {--unused : Check for defined translation keys that are never used in codebase} + {--no-implicit : Disable heuristic detection of key-like strings in Mail/Notification classes} + {--fix : Add missing keys to the language files using placeholder values} + {--purge : Remove unused keys from the language files (implies --unused)} + {--dry-run : Preview what --fix or --purge would change without writing anything} + {--force : Do not ask for confirmation before writing files} + {--mark= : Prefix for placeholder values created by --fix, for example TODO} + {--all-locales : With --purge, purge every locale instead of only the given one} + {--include-json : With --purge, also remove unused JSON keys (risky, strings used by vendor packages are not detected)}'; + + protected $description = 'Check for translation keys used in the application that are missing or defined keys that are unused'; + + /** + * Penanda wildcard internal untuk bagian key yang dinamis. + */ + private const WILDCARD = "\x1A"; + + /** + * Pemanggil fungsi translasi yang dikenali: + * - __('x'), trans('x'), trans_choice('x') + * - @lang('x'), @choice('x') + * - Lang::get/choice/has/hasForLocale('x') + * - trans()->get('x'), app('translator')->get('x') + */ + private const CALL_PATTERN = <<<'REGEX' +~ +(?: + (?])(?:trans\(\)|app\(\s*['"]translator['"]\s*\))\s*->\s*(?:get|choice|has) + | (?:])(?:__|trans_choice|trans) + | @(?:lang|choice) + | (?])Lang::(?:get|choice|has|hasForLocale|getFromJson) +) +\s*\(\s*(?:key\s*:\s*)? +~x +REGEX; + + /** String literal PHP (single/double quote, mendukung escape, tanpa newline). */ + private const LITERAL_PATTERN = '/(?|\'((?:[^\'\\\\\r\n]|\\\\.)*)\'|"((?:[^"\\\\\r\n]|\\\\.)*)")/'; + + /** + * Awal atribut yang dipindai: #[Name(, termasuk namespace penuh dan atribut tanpa argumen + * di depannya (#[Foo, Layout(...)]). Penutup ')' dicari oleh findClosing(). + */ + private const ATTRIBUTE_PATTERN = '~#\[(?:[^\]\(]*,\s*)?(?:\\\\?[\w\\\\]*\\\\)?(Seo|LayoutData|Layout|Title)\s*\(~'; + + /** Bentuk key bertipe dot-notation: group[/sub].key[.nested] */ + private const KEY_SHAPE = '/^[A-Za-z0-9_\-]+(?:\/[A-Za-z0-9_\-]+)*(?:\.[A-Za-z0-9_\-]+)+$/'; + + /** + * Direktori (relatif terhadap base_path) yang dipindai. + * Mail & Notifications ada di app/, view mail di resources/views (termasuk views/vendor/mail). + */ + protected array $scanPaths = [ + 'app', + 'routes', + 'config', + 'database', + 'resources/views', + ]; + + /** + * Pengecualian default. Di-anchor ke base_path(), jadi 'vendor' hanya mengecualikan + * /vendor di root project, BUKAN resources/views/vendor/mail atau views/vendor/notifications. + */ + protected array $defaultExclusions = [ + 'vendor', + 'storage', + 'bootstrap/cache', + 'node_modules', + '.git', + ]; + + /** + * Nama folder yang kelas/view-nya sering menyimpan key sebagai string biasa + * (mis. 'title' => 'notifications.order.title' yang diterjemahkan belakangan). + */ + protected array $implicitKeySegments = [ + 'Notifications', + 'Notification', + 'Mail', + 'Mails', + 'Mailables', + ]; + + /** + * Suffix yang otomatis dihasilkan untuk setiap generatePolicy('model-name'): + * permissions.{model-name}.{suffix}. Tambahkan suffix lain di sini bila perlu. + */ + protected array $permissionKeySuffixes = [ + 'group-name', + 'viewAny', + 'view', + 'create', + 'update', + 'delete', + ]; + + /** + * Cara membaca argumen tiap atribut (lihat docblock scanAttributes()). + * 'named' = berdasarkan nama parameter, 'positional' = berdasarkan urutan argumen. + * Parameter yang tidak terdaftar diperlakukan sebagai 'guess'. + */ + protected array $attributeRules = [ + 'Title' => [ + 'named' => ['content' => 'key'], + 'positional' => ['key'], + ], + 'Layout' => [ + 'named' => ['name' => 'ignore', 'view' => 'ignore', 'params' => 'values', 'data' => 'values'], + 'positional' => ['ignore', 'values'], + ], + 'LayoutData' => [ + 'named' => ['header' => 'key', 'breadcrumbs' => 'labels', 'context' => 'ignore'], + 'positional' => ['key', 'labels'], + ], + 'Seo' => [ + 'named' => [], + 'positional' => [], + ], + ]; + + protected array $ignoredKeys = ['key']; + + protected array $ignoredUnusedFiles = [ + 'validation', + 'auth', + 'pagination', + 'passwords', + ]; + + /** Semua string literal berbentuk key yang ditemukan di codebase (untuk --unused). */ + protected array $literalKeys = []; + + protected int $scannedFiles = 0; + + public function handle(): int + { + $locale = $this->argument('locale'); + $purge = (bool) $this->option('purge'); + $fix = (bool) $this->option('fix'); + + if ($purge && $fix) { + $this->error('--fix and --purge cannot be combined. Run them separately.'); + + return Command::FAILURE; + } + + if (($this->option('all-locales') || $this->option('include-json')) && ! $purge) { + $this->error('--all-locales and --include-json can only be used together with --purge.'); + + return Command::FAILURE; + } + + $this->info("Checking translation keys against locale: [{$locale}]..."); + + $langData = $this->loadLanguageData($locale); + $scan = $this->scanCodebase($langData); + + $this->line("Scanned {$this->scannedFiles} file(s), found " . count($scan['keys']) . ' static key(s) and ' . count($scan['patterns']) . ' dynamic pattern(s).'); + $this->newLine(); + + if ($this->option('unused') || $purge) { + return $this->checkUnusedKeys($locale, $langData, $scan); + } + + $missingKeys = []; + + foreach ($scan['keys'] as $key => $occurrences) { + if (! $this->hasKey($langData, $key)) { + $missingKeys[$key] = $occurrences; + } + } + + if (empty($missingKeys)) { + $this->info('All translation keys are properly indexed!'); + + return Command::SUCCESS; + } + + $this->error('Found ' . count($missingKeys) . ' missing translation key(s):'); + $this->newLine(); + + $tableData = []; + foreach ($missingKeys as $key => $occurrences) { + foreach ($occurrences as $occ) { + $tableData[] = [$key, $occ['file'] . ':' . $occ['line']]; + } + } + + $this->table(['Missing Key', 'Used In'], $tableData); + + if ($fix) { + return $this->fixMissingKeys($locale, $langData, $missingKeys); + } + + if ($this->option('ai')) { + $this->outputAiPrompt($locale, $missingKeys); + } else { + $this->newLine(); + $this->comment('Tip: Run with --ai to generate a copyable prompt for AI to fix these keys:'); + $this->line(" php artisan lang:check {$locale} --ai"); + $this->comment('Or add placeholders to the language files (preview first with --dry-run):'); + $this->line(" php artisan lang:check {$locale} --fix --dry-run"); + } + + return Command::FAILURE; + } - #[Locked] - public ?string $id = null; + // ------------------------------------------------------------------ + // Unused keys + // ------------------------------------------------------------------ - public function mount(string $user_id): void + private function checkUnusedKeys(string $locale, array $langData, array $scan): int { - $this->id = $user_id; + $unusedKeys = $this->findUnusedKeys($langData, $scan); + + if (empty($unusedKeys)) { + $this->info('No unused translation keys found! All defined keys are actively used.'); + + return Command::SUCCESS; + } + + $this->warn('Found ' . count($unusedKeys) . ' unused translation key(s) in language files:'); + $this->newLine(); + + $this->table(['Unused Defined Key'], array_map(fn (string $key) => [$key], $unusedKeys)); + + if ($this->option('purge')) { + return $this->purgeUnusedKeys($locale, $langData, $scan, $unusedKeys); + } + + $this->newLine(); + $this->comment('Tip: remove them from the language files (preview first with --dry-run):'); + $this->line(" php artisan lang:check {$locale} --purge --dry-run"); + + return Command::FAILURE; + } + + /** + * @return array Defined keys that nothing in the codebase uses. + */ + private function findUnusedKeys(array $langData, array $scan): array + { + $allDefinedKeys = $this->flattenLangData($langData); + $unusedKeys = []; + + $usedKeys = []; + $markUsed = function (string $key) use (&$usedKeys): void { + $usedKeys[$key] = true; + $usedKeys[str_replace('/', '.', $key)] = true; + }; + + foreach (array_keys($scan['keys']) as $key) { + $markUsed($key); + } + + // String literal berbentuk key di manapun (mis. array config, return value method, dll.) + foreach (array_keys($this->literalKeys) as $key) { + $markUsed($key); + } + + $patterns = []; + foreach (array_keys($scan['patterns']) as $pattern) { + $patterns[] = $pattern; + $patterns[] = str_replace('/', '.', $pattern); + } + $patterns = array_unique($patterns); + + foreach ($allDefinedKeys as $definedKey) { + if ($this->isIgnoredUnusedKey($definedKey)) { + continue; + } + + $normalizedDefinedKey = str_replace('/', '.', $definedKey); + + if (isset($usedKeys[$definedKey]) || isset($usedKeys[$normalizedDefinedKey])) { + continue; + } + + // __('group.parent') mengembalikan seluruh array, jadi semua turunannya ikut terpakai. + $segments = explode('.', $definedKey); + for ($length = count($segments) - 1; $length >= 2; $length--) { + if (isset($usedKeys[implode('.', array_slice($segments, 0, $length))])) { + continue 2; + } + } + + foreach ($patterns as $pattern) { + if (Str::is($pattern, $definedKey) || Str::is($pattern, $normalizedDefinedKey)) { + continue 2; + } + } + + $unusedKeys[] = $definedKey; + } + + return $unusedKeys; } - #[On('refresh-user-data')] - #[Computed] - public function user(): ?User + private function isIgnoredUnusedKey(string $key): bool { - return $this->id ? User::with('profile') - ->where('ulid', $this->id) - ->first() : new User; + foreach ($this->ignoredUnusedFiles as $ignored) { + if ( + str_starts_with($key, "{$ignored}.") + || str_contains($key, "/{$ignored}.") + || str_contains($key, ".{$ignored}.") + ) { + return true; + } + } + + return false; } - #[On('send-password-reset')] - public function sendResetPassword(SendPasswordResetLink $sendLink): void + private function flattenLangData(array $langData): array { - try { - $sendLink->execute(new ForgotPasswordDTO( - email: $this->user->email, - )); + $flattened = []; + + foreach ($langData as $prefix => $data) { + if ($prefix === '__json') { + foreach (array_keys($data) as $jsonKey) { + $flattened[] = (string) $jsonKey; + } + + continue; + } - $this->dispatch('send-password-reset-completed'); - $this->success(__('domains/identity/messages.success.password_reset_link_sent')); - } catch (Exception $e) { - $this->dispatch('send-password-reset-failed', message: $e->getMessage()); + array_push($flattened, ...$this->flattenArray($data, (string) $prefix)); } + + return $flattened; } - #[On('toggle-user-status')] - public function toggleStatus(SuspendUser $suspendUser, ActivateUserStatus $activateUserStatus): void + /** + * Meratakan array menjadi key dot-notation. Array list (indeks 0..n) diperlakukan + * sebagai SATU key, karena dipakai utuh lewat __('group.key') yang mengembalikan array. + * + * @return array + */ + private function flattenArray(array $data, string $prefix): array { - try { - if ($this->user->status->isActive()) { - $suspendUser->execute($this->user); + $keys = []; + + foreach ($data as $key => $value) { + $path = "{$prefix}.{$key}"; + + if (is_array($value) && $value !== [] && array_keys($value) !== range(0, count($value) - 1)) { + array_push($keys, ...$this->flattenArray($value, $path)); } else { - $activateUserStatus->execute($this->user); + $keys[] = $path; + } + } + + return $keys; + } + + // ------------------------------------------------------------------ + // Scanning + // ------------------------------------------------------------------ + + /** + * @return array{keys: array, patterns: array} + */ + private function scanCodebase(array $langData): array + { + $keys = []; + $patterns = []; + $this->literalKeys = []; + $this->scannedFiles = 0; + + $cliSkips = array_filter((array) ($this->option('skip') ?? [])); + $basePath = rtrim(str_replace('\\', '/', base_path()), '/'); + $selfPath = str_replace('\\', '/', __FILE__); + + foreach ($this->scanPaths as $path) { + $dir = base_path($path); + + if (! File::isDirectory($dir)) { + continue; + } + + $finder = Finder::create()->files()->in($dir)->name('*.php'); + + foreach ($finder as $file) { + $absolutePath = str_replace('\\', '/', $file->getPathname()); + + if ($absolutePath === $selfPath) { + continue; + } + + $relativePath = ltrim(substr($absolutePath, strlen($basePath)), '/'); + + if ($this->isExcluded($relativePath, $cliSkips)) { + continue; + } + + $this->scannedFiles++; + $this->scanFile($file->getContents(), $relativePath, $langData, $keys, $patterns); + } + } + + return ['keys' => $keys, 'patterns' => $patterns]; + } + + private function isExcluded(string $relativePath, array $cliSkips): bool + { + foreach ($this->defaultExclusions as $excluded) { + $excluded = trim(str_replace('\\', '/', $excluded), '/'); + + if ($relativePath === $excluded || str_starts_with($relativePath, $excluded . '/')) { + return true; + } + } + + foreach ($cliSkips as $skip) { + $skip = trim(str_replace('\\', '/', (string) $skip), '/'); + + if ($skip === '') { + continue; + } + + if (str_contains($skip, '*')) { + if (Str::is($skip, $relativePath) || Str::is('*/' . $skip, $relativePath)) { + return true; + } + + continue; + } + + if ( + $relativePath === $skip + || str_starts_with($relativePath, $skip . '/') + || str_contains($relativePath, '/' . $skip . '/') + || str_ends_with($relativePath, '/' . $skip) + ) { + return true; + } + } + + return false; + } + + private function scanFile(string $content, string $file, array $langData, array &$keys, array &$patterns): void + { + $content = $this->stripComments($content); + + // 1 & 2. Pemanggilan helper translasi (statis maupun dinamis) + $this->scanTranslationCalls($content, $file, $keys, $patterns); + + // 3. Atribut #[Seo(...)], #[LayoutData(...)], #[Layout(...)], #[Title(...)] + $this->scanAttributes($content, $file, $keys); + + // 4. String literal berbentuk key (untuk --unused & heuristik Mail/Notifications) + $this->scanLiteralKeys($content, $file, $langData, $keys); + + // 5. Enum dengan trait InteractsWithLabels + if (str_contains($content, 'InteractsWithLabels') && str_contains($content, 'enum ')) { + foreach ($this->extractEnumTranslationKeys($content) as $info) { + $this->addOccurrence($keys, $info['key'], $file, $info['line']); + } + } + + // 6. Method permissions() & generatePolicy() + if (str_contains($content, 'function permissions(')) { + foreach ($this->extractPermissionTranslationKeys($content) as $info) { + if ($info['dynamic'] ?? false) { + $this->addOccurrence($patterns, $info['key'], $file, $info['line']); + } else { + $this->addOccurrence($keys, $info['key'], $file, $info['line']); + } + } + } + } + + private function scanTranslationCalls(string $content, string $file, array &$keys, array &$patterns): void + { + if (! preg_match_all(self::CALL_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE)) { + return; + } + + foreach ($matches[0] as [$text, $offset]) { + $line = $this->lineAt($content, $offset); + + foreach ($this->parseFirstArgument($content, $offset + strlen($text)) as $result) { + if ($result['dynamic']) { + $this->addOccurrence($patterns, $result['value'], $file, $line); + + continue; + } + + if (! $this->isInvalidKey($result['value'])) { + $this->addOccurrence($keys, $result['value'], $file, $line); + } + } + } + } + + /** + * Mem-parse argumen pertama dari pemanggilan translasi. + * + * Mendukung: + * - 'key' / "key" (termasuk apostrof & escape, mis. __("You're receiving this email")) + * - 'prefix.' . $var . '.suffix' => pola dinamis "prefix.*.suffix" + * - "prefix.{$var}.suffix" => pola dinamis "prefix.*.suffix" + * - $cond ? 'a.b' : 'a.c' => kedua literal dicatat sebagai key statis + * - __('key') . ':' => bukan dinamis (konkat di luar pemanggilan) + * + * @return array + */ + private function parseFirstArgument(string $content, int $pos): array + { + $len = strlen($content); + $operands = []; + $buffer = ''; + $depth = 0; + + for ($i = $pos; $i < $len; $i++) { + $char = $content[$i]; + + if ($char === "'" || $char === '"') { + $start = $i; + + for ($i++; $i < $len && $content[$i] !== $char; $i++) { + if ($content[$i] === '\\') { + $i++; + } + } + + $buffer .= substr($content, $start, $i - $start + 1); + + continue; + } + + if ($char === '(' || $char === '[' || $char === '{') { + $depth++; + } elseif ($char === ')' || $char === ']' || $char === '}') { + if ($depth === 0) { + break; + } + $depth--; + } elseif ($depth === 0 && $char === ',') { + break; + } elseif ($depth === 0 && $char === '.') { + $prev = $content[$i - 1] ?? ''; + $next = $content[$i + 1] ?? ''; + + $isSpread = $prev === '.' || $next === '.'; + $isDecimal = ctype_digit($prev) && ctype_digit($next); + + if (! $isSpread && ! $isDecimal) { + $operands[] = trim($buffer); + $buffer = ''; + + continue; + } + } + + $buffer .= $char; + } + + $operands[] = trim($buffer); + + if ($operands[0] === '') { + return []; // trans() / __() tanpa argumen + } + + $value = implode('', array_map(fn (string $op) => $this->decodeOperand($op), $operands)); + $value = preg_replace('/' . self::WILDCARD . '+/', self::WILDCARD, $value); + + // Sepenuhnya dinamis (mis. __($key)): ambil literal berbentuk key dari ekspresi (ternary/match). + if ($value === self::WILDCARD) { + $results = []; + + foreach ($this->extractLiterals(implode(' ', $operands)) as [$literal]) { + if (preg_match(self::KEY_SHAPE, $literal)) { + $results[] = ['value' => $literal, 'dynamic' => false]; + } + } + + return $results; + } + + if (str_contains($value, self::WILDCARD)) { + return [['value' => str_replace(self::WILDCARD, '*', $value), 'dynamic' => true]]; + } + + return [['value' => $value, 'dynamic' => false]]; + } + + private function decodeOperand(string $operand): string + { + if (preg_match('/^\'((?:[^\'\\\\]|\\\\.)*)\'$/s', $operand, $m)) { + return str_replace(["\\'", '\\\\'], ["'", '\\'], $m[1]); + } + + if (preg_match('/^"((?:[^"\\\\]|\\\\.)*)"$/s', $operand, $m)) { + $inner = preg_replace( + '/\{\$(?:[^{}]|\{[^{}]*\})*\}|(?\w+|\[[^\]]*\])*/', + self::WILDCARD, + $m[1] + ); + + return stripcslashes($inner); + } + + return self::WILDCARD; + } + + /** + * Memindai atribut #[Seo], #[LayoutData], #[Layout], #[Title] berdasarkan struktur argumennya: + * + * - 'key' : nilai string adalah translation key. + * - 'labels' : KEY array adalah translation key, nilainya (URL/route) diabaikan. + * - 'values' : NILAI array berbentuk key adalah translation key, key array (nama variabel) diabaikan. + * - 'ignore' : diabaikan (nama view, context, dst.). + * - 'guess' : parameter yang tidak dikenal; hanya string berbentuk key (group.item) yang dihitung. + * + * Aturan per atribut ada di $attributeRules. + */ + private function scanAttributes(string $content, string $file, array &$keys): void + { + if (! preg_match_all(self::ATTRIBUTE_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE)) { + return; + } + + foreach ($matches[0] as $index => [$text, $offset]) { + $name = $matches[1][$index][0]; + $open = $offset + strlen($text) - 1; + $close = $this->findClosing($content, $open); + + if ($close === null) { + continue; + } + + $argsOffset = $open + 1; + $argsText = $this->blankComments(substr($content, $argsOffset, $close - $open - 1)); + $rules = $this->attributeRules[$name] ?? ['named' => [], 'positional' => []]; + $position = 0; + + foreach ($this->splitTopLevel($argsText, ',', $argsOffset) as [$arg, $argOffset]) { + if (trim($arg) === '') { + continue; + } + + if (preg_match('/^\s*([A-Za-z_]\w*)\s*:(?!:)\s*/', $arg, $named)) { + $mode = $rules['named'][$named[1]] ?? 'guess'; + $expr = substr($arg, strlen($named[0])); + $exprOffset = $argOffset + strlen($named[0]); + } else { + $mode = $rules['positional'][$position++] ?? 'guess'; + $expr = $arg; + $exprOffset = $argOffset; + } + + $this->collectAttributeKeys($content, $expr, $exprOffset, $mode, $file, $keys); + } + } + } + + private function collectAttributeKeys(string $content, string $expr, int $offset, string $mode, string $file, array &$keys): void + { + switch ($mode) { + case 'ignore': + return; + + case 'key': + $this->recordAttributeLiteral($content, $expr, $offset, false, $file, $keys); + + return; + + case 'labels': + foreach ($this->parseArrayEntries($expr, $offset) as [$keyExpr, $keyOffset, $valueExpr, $valueOffset]) { + // ['label' => 'url'] -> label adalah key; ['label'] (tanpa key) -> nilainya adalah label + if ($keyExpr !== null) { + $this->recordAttributeLiteral($content, $keyExpr, $keyOffset, false, $file, $keys); + } else { + $this->recordAttributeLiteral($content, $valueExpr, $valueOffset, false, $file, $keys); + } + } + + return; + + case 'values': + foreach ($this->parseArrayEntries($expr, $offset) as [, , $valueExpr, $valueOffset]) { + $this->recordAttributeLiteral($content, $valueExpr, $valueOffset, true, $file, $keys); + } + + return; + + default: // 'guess' + foreach ($this->extractLiterals($expr) as [$literal, $literalOffset]) { + if (preg_match(self::KEY_SHAPE, $literal) && ! $this->isInvalidKey($literal)) { + $this->addOccurrence($keys, $literal, $file, $this->lineAt($content, $offset + $literalOffset)); + } + } + } + } + + private function recordAttributeLiteral(string $content, string $expr, int $offset, bool $requireKeyShape, string $file, array &$keys): void + { + $value = $this->parseStringLiteral($expr); + + if ($value === null || $value === '' || $this->isPlaceholderLabel($value) || $this->isInvalidKey($value)) { + return; + } + + if ($requireKeyShape && ! preg_match(self::KEY_SHAPE, $value)) { + return; + } + + $lead = strlen($expr) - strlen(ltrim($expr)); + $this->addOccurrence($keys, $value, $file, $this->lineAt($content, $offset + $lead)); + } + + /** + * Label yang seluruhnya berupa placeholder dari context (mis. '{name}') bukan translation key. + */ + private function isPlaceholderLabel(string $value): bool + { + return trim((string) preg_replace('/\{[^{}]*\}/', '', $value)) === ''; + } + + /** + * String literal tunggal (tanpa interpolasi) => nilainya; selain itu null. + */ + private function parseStringLiteral(string $expr): ?string + { + $expr = trim($expr); + + if (preg_match('/^\'((?:[^\'\\\\]|\\\\.)*)\'$/s', $expr, $m)) { + return str_replace(["\\'", '\\\\'], ["'", '\\'], $m[1]); + } + + if (preg_match('/^"((?:[^"\\\\]|\\\\.)*)"$/s', $expr, $m)) { + return preg_match('/(? [keyExpr, keyOffset, valueExpr, valueOffset] + */ + private function parseArrayEntries(string $expr, int $offset): array + { + $trimmed = trim($expr); + $lead = strlen($expr) - strlen(ltrim($expr)); + + if (str_starts_with($trimmed, '[') && str_ends_with($trimmed, ']')) { + $innerStart = 1; + $inner = substr($trimmed, 1, -1); + } elseif (preg_match('/^array\s*\(/i', $trimmed, $m) && str_ends_with($trimmed, ')')) { + $innerStart = strlen($m[0]); + $inner = substr($trimmed, $innerStart, -1); + } else { + return []; + } + + $entries = []; + + foreach ($this->splitTopLevel($inner, ',', $offset + $lead + $innerStart) as [$entry, $entryOffset]) { + if (trim($entry) === '') { + continue; + } + + $pair = $this->splitTopLevel($entry, '=>', $entryOffset); + + $entries[] = count($pair) >= 2 + ? [$pair[0][0], $pair[0][1], $pair[1][0], $pair[1][1]] + : [null, 0, $pair[0][0], $pair[0][1]]; + } + + return $entries; + } + + /** + * Memecah teks pada $separator di kedalaman nol (sadar string & kurung). + * + * @return array [segmen, offset absolut] + */ + private function splitTopLevel(string $text, string $separator, int $baseOffset): array + { + $parts = []; + $length = strlen($text); + $separatorLength = strlen($separator); + $depth = 0; + $start = 0; + + for ($i = 0; $i < $length; $i++) { + $char = $text[$i]; + + if ($char === "'" || $char === '"') { + $i = $this->skipString($text, $i); + + continue; + } + + if ($char === '(' || $char === '[' || $char === '{') { + $depth++; + } elseif ($char === ')' || $char === ']' || $char === '}') { + $depth--; + } elseif ($depth === 0 && substr($text, $i, $separatorLength) === $separator) { + $parts[] = [substr($text, $start, $i - $start), $baseOffset + $start]; + $start = $i + $separatorLength; + $i += $separatorLength - 1; + } + } + + $parts[] = [substr($text, $start), $baseOffset + $start]; + + return $parts; + } + + /** + * Posisi kurung penutup yang berpasangan dengan kurung pembuka di $open. + */ + private function findClosing(string $content, int $open): ?int + { + $length = strlen($content); + $depth = 0; + + for ($i = $open; $i < $length; $i++) { + $char = $content[$i]; + + if ($char === "'" || $char === '"') { + $i = $this->skipString($content, $i); + + continue; } - $this->dispatch('toggle-user-status-completed'); - $this->success(__('domains/identity/messages.success.user_status_updated')); - } catch (Exception $e) { - $this->dispatch('toggle-user-status-failed', message: $e->getMessage()); + if ($char === '(' || $char === '[' || $char === '{') { + $depth++; + } elseif ($char === ')' || $char === ']' || $char === '}') { + if (--$depth === 0) { + return $i; + } + } } + + return null; + } + + /** + * Indeks tanda kutip penutup dari string yang dibuka di $i. + */ + private function skipString(string $text, int $i): int + { + $quote = $text[$i]; + $length = strlen($text); + + for ($i++; $i < $length && $text[$i] !== $quote; $i++) { + if ($text[$i] === '\\') { + $i++; + } + } + + return min($i, $length - 1); + } + + /** + * Mengganti komentar (// , # , /* *​/) di luar string dengan spasi, panjang teks tetap + * sehingga offset & nomor baris tidak bergeser. + */ + private function blankComments(string $text): string + { + $out = $text; + $length = strlen($text); + + for ($i = 0; $i < $length; $i++) { + $char = $text[$i]; + $next = $text[$i + 1] ?? ''; + + if ($char === "'" || $char === '"') { + $i = $this->skipString($text, $i); + } elseif (($char === '/' && $next === '/') || $char === '#') { + for (; $i < $length && $text[$i] !== "\n"; $i++) { + $out[$i] = ' '; + } + } elseif ($char === '/' && $next === '*') { + $end = strpos($text, '*/', $i + 2); + $end = $end === false ? $length - 1 : $end + 1; + + for ($j = $i; $j <= $end; $j++) { + if ($out[$j] !== "\n") { + $out[$j] = ' '; + } + } + + $i = $end; + } + } + + return $out; + } + + /** + * Mengumpulkan semua string literal berbentuk dot-notation key. + * - Selalu disimpan ke $this->literalKeys (dipakai --unused). + * - Pada file Mail/Notifications, jika group-nya ada di file lang, dianggap sebagai + * key yang dipakai (mis. 'title' => 'notifications.order.title'). + */ + private function scanLiteralKeys(string $content, string $file, array $langData, array &$keys): void + { + $isImplicitFile = ! $this->option('no-implicit') && $this->isImplicitFile($file); + + foreach ($this->extractLiterals($content) as [$literal, $offset]) { + if (! preg_match(self::KEY_SHAPE, $literal)) { + continue; + } + + $this->literalKeys[$literal] = true; + + if (! $isImplicitFile || ! $this->groupExists($langData, $literal)) { + continue; + } + + // Hindari nama view/route/config yang kebetulan berbentuk key + $ctxStart = max(0, $offset - 41); + $context = substr($content, $ctxStart, max(0, $offset - 1 - $ctxStart)); + + if (preg_match('/(?:view|markdown|html|text|route|config|queue|connection|disk)\s*(?:\(|:|=>)\s*$/i', $context)) { + continue; + } + + if (! $this->isInvalidKey($literal)) { + $this->addOccurrence($keys, $literal, $file, $this->lineAt($content, $offset)); + } + } + } + + private function isImplicitFile(string $file): bool + { + foreach ($this->implicitKeySegments as $segment) { + if (preg_match('~(?:^|/)' . preg_quote($segment, '~') . '/~', $file)) { + return true; + } + } + + return false; + } + + private function groupExists(array $langData, string $key): bool + { + $group = explode('.', $key, 2)[0]; + + return $group !== '' && $group !== '__json' && isset($langData[$group]); + } + + private function extractLiterals(string $content): array + { + if (! preg_match_all(self::LITERAL_PATTERN, $content, $matches, PREG_OFFSET_CAPTURE)) { + return []; + } + + return $matches[1]; + } + + /** + * Hapus komentar tanpa menggeser nomor baris: + * komentar Blade, blok komentar di awal baris, dan baris yang diawali dua slash. + */ + private function stripComments(string $content): string + { + $blank = static fn (array $m): string => str_repeat("\n", substr_count($m[0], "\n")); + + $content = preg_replace_callback('/\{\{--.*?--\}\}/s', $blank, $content) ?? $content; + $content = preg_replace_callback('~^[ \t]*/\*.*?\*/~ms', $blank, $content) ?? $content; + + // Catatan: baris diawali '#' sengaja TIDAK dihapus (heading Markdown pada template mail). + return preg_replace('~^[ \t]*//.*$~m', '', $content) ?? $content; + } + + private function lineAt(string $content, int $offset): int + { + return substr_count($content, "\n", 0, $offset) + 1; + } + + private function addOccurrence(array &$bucket, string $key, string $file, int $line): void + { + $bucket[$key]["{$file}:{$line}"] = ['file' => $file, 'line' => $line]; + } + + // ------------------------------------------------------------------ + // Extractors khusus project + // ------------------------------------------------------------------ + + private function extractPermissionTranslationKeys(string $content): array + { + $keys = []; + + foreach (explode("\n", $content) as $lineNum => $line) { + if (preg_match('/[\'"]description[\'"]\s*=>\s*[\'"]([^\'"]+)[\'"]/', $line, $matches)) { + if (! $this->isInvalidKey($matches[1])) { + $keys[] = ['key' => $matches[1], 'line' => $lineNum + 1]; + } + } + } + + if (preg_match_all('/generatePolicy\(\s*[\'"]([^\'"]+)[\'"]/', $content, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches[1] as [$modelName, $offset]) { + $line = $this->lineAt($content, $offset); + + // Key yang wajib ada (dicek sebagai missing) + foreach ($this->permissionKeySuffixes as $suffix) { + $keys[] = ['key' => "permissions.{$modelName}.{$suffix}", 'line' => $line]; + } + + // Pola dinamis: seluruh permissions.{model}.* dianggap terpakai (untuk --unused) + $keys[] = ['key' => "permissions.{$modelName}.*", 'line' => $line, 'dynamic' => true]; + } + } + + return $keys; + } + + private function extractEnumTranslationKeys(string $content): array + { + if (! preg_match('/namespace\s+([^;]+);/', $content, $namespaceMatch)) { + return []; + } + + if (! preg_match('/enum\s+([^\s:]+)/', $content, $enumMatch)) { + return []; + } + + $fqcn = trim($namespaceMatch[1]) . '\\' . trim($enumMatch[1]); + $enumSlug = Str::snake(trim($enumMatch[1])); + + if (Str::startsWith($fqcn, 'App\\Domains\\')) { + $domain = Str::between($fqcn, 'App\\Domains\\', '\\Enums\\'); + $keyPrefix = 'domains/' . Str::kebab($domain) . "/enum.{$enumSlug}"; + } elseif (Str::startsWith($fqcn, 'App\\UI\\Enums\\')) { + $keyPrefix = "ui/enum.{$enumSlug}"; + } else { + $keyPrefix = "enum.{$enumSlug}"; + } + + $keys = []; + + foreach (explode("\n", $content) as $lineNum => $line) { + if (preg_match('/case\s+[A-Za-z0-9_]+\s*=\s*[\'"]([^\'"]+)[\'"]\s*;/', $line, $caseMatch)) { + $keys[] = ['key' => "{$keyPrefix}.{$caseMatch[1]}", 'line' => $lineNum + 1]; + } + } + + return $keys; + } + + // ------------------------------------------------------------------ + // Language data + // ------------------------------------------------------------------ + + private function loadLanguageData(string $locale): array + { + $langData = []; + $langPath = lang_path($locale); + $jsonPath = lang_path("{$locale}.json"); + + if (File::exists($jsonPath)) { + $langData['__json'] = json_decode(File::get($jsonPath), true) ?? []; + } + + if (File::isDirectory($langPath)) { + foreach (File::allFiles($langPath) as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $relativePath = str_replace('\\', '/', $file->getRelativePathname()); + $content = require $file->getPathname(); + + if (is_array($content)) { + $langData[substr($relativePath, 0, -4)] = $content; + } + } + } + + return $langData; + } + + /** + * Resolusi sama seperti Laravel: tanpa titik => key JSON; selain itu + * "group[/sub].item.nested" => file lang/{locale}/group[/sub].php. + */ + private function hasKey(array $langData, string $key): bool + { + if (isset($langData['__json']) && array_key_exists($key, $langData['__json'])) { + return true; + } + + if (! str_contains($key, '.')) { + return false; + } + + [$group, $item] = explode('.', $key, 2); + + return isset($langData[$group]) && $item !== '' && Arr::has($langData[$group], $item); + } + + private function isInvalidKey(string $key): bool + { + return $key === '' + || str_contains($key, '::') // key milik package (namespace::group.key) tidak bisa diverifikasi + || preg_match('/\$[A-Za-z_{]/', $key) === 1 + || str_contains($key, '...') + || str_contains($key, ')]') + || in_array($key, $this->ignoredKeys, true); + } + + // ------------------------------------------------------------------ + // Fix (add missing keys) & purge (remove unused keys) + // ------------------------------------------------------------------ + + private function fixMissingKeys(string $locale, array $langData, array $missingKeys): int + { + ksort($missingKeys); + + $mark = (string) ($this->option('mark') ?? ''); + $mark = $mark === '' ? '' : rtrim($mark) . ' '; + + $data = $langData; + $dirty = []; + $added = []; + $conflicts = []; + + foreach (array_keys($missingKeys) as $key) { + [$bucket, $item] = $this->resolveTarget($key); + + if ($bucket === '__json') { + $value = $mark . $key; + $data['__json'][$key] = $value; + } else { + $value = $mark . $this->humanize($item); + $data[$bucket] ??= []; + + if (! $this->setPath($data[$bucket], $item, $value)) { + $conflicts[] = $key; + + continue; + } + } + + $dirty[$bucket] = true; + $added[$key] = ['bucket' => $bucket, 'value' => $value]; + } + + // File yang berisi kode PHP (closure, konstanta, pemanggilan fungsi) tidak aman ditulis ulang. + $skippedFiles = []; + foreach (array_keys($dirty) as $bucket) { + if ($bucket === '__json') { + continue; + } + + $path = lang_path("{$locale}/{$bucket}.php"); + + if (! $this->isSafelyRewritable($path)) { + $skippedFiles[$bucket] = $path; + unset($dirty[$bucket]); + } + } + + $rows = []; + foreach ($added as $key => $info) { + if (isset($skippedFiles[$info['bucket']])) { + continue; + } + + $rows[] = [$key, $info['value'], $this->langFileLabel($locale, $info['bucket'])]; + } + + $this->newLine(); + + if (! empty($rows)) { + $this->info('The following keys will be added:'); + $this->table(['Key', 'Placeholder Value', 'File'], $rows); + } + + foreach ($conflicts as $key) { + $this->error("Cannot add [{$key}]: a parent segment already holds a plain string value. Resolve it manually."); + } + + foreach ($skippedFiles as $bucket => $path) { + $this->warn('Skipped ' . $this->relativePath($path) . ': it contains executable PHP (closures, constants, function calls) and cannot be rewritten safely. Add its keys manually.'); + } + + if (empty($dirty)) { + return Command::FAILURE; + } + + if (! $this->shouldWrite('Write ' . count($rows) . ' key(s) into ' . count($dirty) . ' file(s)? Comments and custom formatting in those files will be lost.')) { + return Command::FAILURE; + } + + foreach (array_keys($dirty) as $bucket) { + $this->writeLangFile($locale, $bucket, $data[$bucket]); + } + + $this->info('Added ' . count($rows) . ' placeholder key(s) to ' . count($dirty) . ' file(s). Replace the placeholder values with real translations.'); + + return (empty($conflicts) && empty($skippedFiles)) ? Command::SUCCESS : Command::FAILURE; + } + + private function purgeUnusedKeys(string $locale, array $langData, array $scan, array $unusedKeys): int + { + $includeJson = (bool) $this->option('include-json'); + $locales = $this->option('all-locales') ? $this->discoverLocales() : [$locale]; + + $plans = []; + $skippedJson = 0; + $skippedFiles = []; + + foreach ($locales as $loc) { + $data = $loc === $locale ? $langData : $this->loadLanguageData($loc); + $unused = $loc === $locale ? $unusedKeys : $this->findUnusedKeys($data, $scan); + $removed = []; + + foreach ($unused as $key) { + $bucket = $this->locateKey($data, $key); + + if ($bucket === null) { + continue; + } + + if ($bucket === '__json') { + if (! $includeJson) { + $skippedJson++; + + continue; + } + + unset($data['__json'][$key]); + } else { + $this->forgetPath($data[$bucket], substr($key, strlen($bucket) + 1)); + } + + $removed[$bucket][] = $key; + } + + foreach (array_keys($removed) as $bucket) { + if ($bucket === '__json') { + continue; + } + + $path = lang_path("{$loc}/{$bucket}.php"); + + if (! $this->isSafelyRewritable($path)) { + $skippedFiles[$path] = true; + unset($removed[$bucket]); + } + } + + if (! empty($removed)) { + $plans[$loc] = ['data' => $data, 'removed' => $removed]; + } + } + + $this->newLine(); + + foreach (array_keys($skippedFiles) as $path) { + $this->warn('Skipped ' . $this->relativePath($path) . ': it contains executable PHP (closures, constants, function calls) and cannot be rewritten safely. Edit it manually.'); + } + + if ($skippedJson > 0) { + $this->comment("{$skippedJson} unused JSON key(s) were left untouched. Use --include-json to remove them as well."); + } + + if (empty($plans)) { + $this->warn('Nothing can be purged automatically.'); + + return Command::FAILURE; + } + + $rows = []; + $total = 0; + $fileCount = 0; + + foreach ($plans as $loc => $plan) { + foreach ($plan['removed'] as $bucket => $keys) { + $rows[] = [$loc, $this->langFileLabel($loc, $bucket), count($keys)]; + $total += count($keys); + $fileCount++; + } + } + + $this->info('The following keys will be removed:'); + $this->table(['Locale', 'File', 'Keys to Remove'], $rows); + + if (! $this->shouldWrite("Remove {$total} key(s) from {$fileCount} file(s)? Comments and custom formatting in those files will be lost.")) { + return Command::FAILURE; + } + + foreach ($plans as $loc => $plan) { + foreach (array_keys($plan['removed']) as $bucket) { + $this->writeLangFile($loc, $bucket, $plan['data'][$bucket] ?? []); + } + } + + $this->info("Removed {$total} unused key(s) from {$fileCount} file(s)."); + + return empty($skippedFiles) ? Command::SUCCESS : Command::FAILURE; + } + + private function shouldWrite(string $question): bool + { + if ($this->option('dry-run')) { + $this->comment('Dry run: no files were written.'); + + return false; + } + + if ($this->option('force')) { + return true; + } + + if (! $this->confirm($question, false)) { + $this->comment('Aborted. No files were written.'); + + return false; + } + + return true; + } + + /** + * Key berbentuk group.item (dot-notation) ditulis ke file PHP group-nya, + * selain itu (kalimat, ada spasi, titik di akhir, dst.) ke file JSON. + * + * @return array{0: string, 1: string} [bucket, item] + */ + private function resolveTarget(string $key): array + { + if (preg_match(self::KEY_SHAPE, $key)) { + [$group, $item] = explode('.', $key, 2); + + return [$group, $item]; + } + + return ['__json', $key]; + } + + /** + * Mencari "bucket" tempat key terdefinisi: '__json' atau nama group file. + */ + private function locateKey(array $data, string $key): ?string + { + if (isset($data['__json']) && array_key_exists($key, $data['__json'])) { + return '__json'; + } + + $group = explode('.', $key, 2)[0]; + + return ($group !== '__json' && isset($data[$group])) ? $group : null; + } + + private function humanize(string $item): string + { + $segments = explode('.', $item); + $last = (string) end($segments); + + $words = preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', $last); + $words = trim(str_replace(['-', '_'], ' ', $words)); + + return ucfirst(mb_strtolower($words)); + } + + /** + * Set nilai pada path dot-notation. Mengembalikan false jika ada segmen + * induk yang sudah berisi nilai non-array (akan menimpa data). + */ + private function setPath(array &$data, string $path, string $value): bool + { + $segments = explode('.', $path); + $last = count($segments) - 1; + $ref = &$data; + + foreach ($segments as $index => $segment) { + if ($index === $last) { + if (array_key_exists($segment, $ref)) { + return false; + } + + $ref[$segment] = $value; + + return true; + } + + if (! array_key_exists($segment, $ref)) { + $ref[$segment] = []; + } + + if (! is_array($ref[$segment])) { + return false; + } + + $ref = &$ref[$segment]; + } + + return false; + } + + /** + * Hapus key pada path dot-notation, lalu bersihkan array induk yang menjadi kosong. + */ + private function forgetPath(array &$data, string $path): void + { + if (array_key_exists($path, $data)) { + unset($data[$path]); + + return; + } + + $this->forgetSegments($data, explode('.', $path)); + } + + private function forgetSegments(array &$data, array $segments): void + { + $first = array_shift($segments); + + if (! array_key_exists($first, $data)) { + return; + } + + if ($segments === []) { + unset($data[$first]); + + return; + } + + if (! is_array($data[$first])) { + return; + } + + $this->forgetSegments($data[$first], $segments); + + if ($data[$first] === []) { + unset($data[$first]); + } + } + + private function writeLangFile(string $locale, string $bucket, array $data): void + { + if ($bucket === '__json') { + $json = json_encode( + $data === [] ? new \stdClass : $data, + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_FORCE_OBJECT + ); + + File::put(lang_path("{$locale}.json"), $json . "\n"); + + return; + } + + $path = lang_path("{$locale}/{$bucket}.php"); + File::ensureDirectoryExists(dirname($path)); + File::put($path, "exportPhpArray($data) . ";\n"); + } + + private function exportPhpArray(array $data, int $depth = 0): string + { + if ($data === []) { + return '[]'; + } + + $indent = str_repeat(' ', $depth + 1); + $isList = array_keys($data) === range(0, count($data) - 1); + $lines = []; + + foreach ($data as $key => $value) { + $keyPart = $isList ? '' : $this->exportScalar($key) . ' => '; + $valuePart = is_array($value) ? $this->exportPhpArray($value, $depth + 1) : $this->exportScalar($value); + + $lines[] = $indent . $keyPart . $valuePart . ','; + } + + return "[\n" . implode("\n", $lines) . "\n" . str_repeat(' ', $depth) . ']'; + } + + private function exportScalar(mixed $value): string + { + return match (true) { + is_string($value) => "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $value) . "'", + is_bool($value) => $value ? 'true' : 'false', + $value === null => 'null', + default => var_export($value, true), + }; + } + + /** + * File lang PHP hanya aman ditulis ulang bila isinya murni literal + * (array, string, angka, true/false/null). Closure, konstanta, konkatenasi, + * atau pemanggilan fungsi akan hilang/berubah jika di-export ulang. + */ + private function isSafelyRewritable(string $path): bool + { + if (! File::exists($path)) { + return true; + } + + $allowedTokens = [ + T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT, T_RETURN, T_ARRAY, + T_DOUBLE_ARROW, T_CONSTANT_ENCAPSED_STRING, T_LNUMBER, T_DNUMBER, T_DECLARE, + ]; + + foreach (token_get_all(File::get($path)) as $token) { + if (is_string($token)) { + if (! str_contains('[],();-+=', $token)) { + return false; + } + + continue; + } + + [$id, $text] = $token; + + if (in_array($id, $allowedTokens, true)) { + continue; + } + + if ($id === T_STRING && in_array(strtolower($text), ['true', 'false', 'null', 'strict_types'], true)) { + continue; + } + + return false; + } + + return true; + } + + private function discoverLocales(): array + { + $locales = []; + + foreach (File::directories(lang_path()) as $dir) { + if (basename($dir) !== 'vendor') { + $locales[] = basename($dir); + } + } + + foreach (File::glob(lang_path('*.json')) ?: [] as $jsonFile) { + $locales[] = basename($jsonFile, '.json'); + } + + return array_values(array_unique($locales)); + } + + private function langFileLabel(string $locale, string $bucket): string + { + return $this->relativePath( + $bucket === '__json' ? lang_path("{$locale}.json") : lang_path("{$locale}/{$bucket}.php") + ); + } + + private function relativePath(string $path): string + { + $base = rtrim(str_replace('\\', '/', base_path()), '/'); + + return ltrim(str_replace($base, '', str_replace('\\', '/', $path)), '/'); + } + + // ------------------------------------------------------------------ + // AI prompt + // ------------------------------------------------------------------ + + private function outputAiPrompt(string $locale, array $missingKeys): void + { + $this->newLine(); + $this->info('==================== AI TRANSLATION PROMPT ===================='); + $this->comment('Copy the block below and paste it into ChatGPT, Claude, Gemini, or Cursor:'); + $this->newLine(); + + $groupedByDomain = []; + foreach ($missingKeys as $key => $occurrences) { + $parts = explode('.', $key); + $domain = count($parts) > 1 ? array_shift($parts) : 'general'; + $groupedByDomain[$domain][$key] = array_column($occurrences, 'file'); + } + + $prompt = "I have missing translation keys in my Laravel project for locale [{$locale}].\n"; + $prompt .= "Please generate the corresponding PHP array files or JSON entries for these keys.\n\n"; + $prompt .= "### Expected Structure:\n"; + $prompt .= "- For domain-prefixed keys (e.g. `domains/identity.field.user.name`), create or update `lang/{$locale}/domains/identity.php`.\n"; + $prompt .= "- Keys without a dot (e.g. `You're receiving this email`) belong in `lang/{$locale}.json`.\n"; + $prompt .= "- Use proper nested PHP arrays representing dot-notation keys.\n"; + $prompt .= "- Translate values naturally for target locale [{$locale}].\n\n"; + $prompt .= "### Missing Keys to Index:\n\n"; + + foreach ($groupedByDomain as $domain => $keys) { + $prompt .= "#### Domain/File: {$domain}\n"; + foreach ($keys as $key => $files) { + $uniqueFiles = array_unique($files); + $prompt .= "- `{$key}` (Used in: " . implode(', ', array_slice($uniqueFiles, 0, 2)) . ")\n"; + } + $prompt .= "\n"; + } + + $prompt .= 'Please output the ready-to-use PHP array code for each file.'; + + $this->line($prompt); + $this->newLine(); + $this->info('==============================================================='); } -}; +} diff --git a/routes/web.php b/routes/web.php index e3aa012..0712513 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Web\Admission\AcademicProgramController; use App\Http\Controllers\Web\Admission\AdmissionScheduleController; use App\Http\Controllers\Web\Admission\AdmissionTrackController; use App\Http\Controllers\Web\Admission\FeeTypeController; +use App\Http\Controllers\Web\Billing\InvoiceController; use App\Http\Controllers\Web\Billing\ProductController; use App\Http\Controllers\Web\Channel\ClientController; use App\Http\Controllers\Web\Identity\RoleController; @@ -68,6 +69,18 @@ Route::middleware(['web', 'auth', 'verified', 'seo', 'layouts'])->group(function Route::get('/products', ProductController::class) ->can('product.viewAny') ->name('products.index'); + Route::get('/invoices', InvoiceController::class) + ->can('invoice.viewAny') + ->name('invoices.index'); + Route::livewire('/invoices/create', 'pages::billing.invoices.create-form-view') + ->can('invoice.create') + ->name('invoices.create'); + Route::livewire('/invoices/{invoice}/update', 'pages::billing.invoices.form-view') + ->can('invoice.update') + ->name('invoices.update'); + Route::livewire('/invoices/{id}', 'pages::billing.invoices.detail-view') + ->can('invoice.view') + ->name('invoices.view'); Route::get('/chart-of-accounts', ChartOfAccountController::class) ->can('coa.viewAny')