Refactor code for improved readability and consistency across multiple files

- Adjusted indentation and formatting in login, permissions, profile, and security pages for better readability.
- Enhanced the clarity of conditional statements and function calls in permissions and profile components.
- Updated type definitions in vite-env.d.ts for better code structure.
- Cleaned up array mapping syntax in ProductTest.php for consistency.
This commit is contained in:
Yoga Pangestu 2026-08-01 10:14:47 +07:00
parent 2a3e70b78d
commit 23cc327190
74 changed files with 5508 additions and 2415 deletions

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\Finance; namespace App\Http\Controllers\Admin\Finance;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\Finance\CashTransactionRequest; use App\Http\Requests\Admin\Finance\CashTransactionRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\CashTransaction; use App\Models\CashTransaction;
use App\Services\Admin\Finance\CashAccountService; use App\Services\Admin\Finance\CashAccountService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\Finance; namespace App\Http\Controllers\Admin\Finance;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest; use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\EmployeeAdvance; use App\Models\EmployeeAdvance;
use App\Services\Admin\Finance\EmployeeAdvanceService; use App\Services\Admin\Finance\EmployeeAdvanceService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\Finance; namespace App\Http\Controllers\Admin\Finance;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\Finance\ExpenseRequest; use App\Http\Requests\Admin\Finance\ExpenseRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Expense; use App\Models\Expense;
use App\Services\Admin\Finance\ExpenseService; use App\Services\Admin\Finance\ExpenseService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;

View File

@ -16,7 +16,7 @@ public function __construct(
public function pay(Payroll $payroll): RedirectResponse public function pay(Payroll $payroll): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn() => $this->service->pay($payroll), fn () => $this->service->pay($payroll),
'Gaji berhasil dibayar.', 'Gaji berhasil dibayar.',
'admin.finance.payroll-periods.show', 'admin.finance.payroll-periods.show',
parameters: ['payroll_period' => $payroll->payroll_period_id] parameters: ['payroll_period' => $payroll->payroll_period_id]
@ -26,7 +26,7 @@ public function pay(Payroll $payroll): RedirectResponse
public function cancel(Payroll $payroll): RedirectResponse public function cancel(Payroll $payroll): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn() => $this->service->cancel($payroll), fn () => $this->service->cancel($payroll),
'Gaji berhasil dibatalkan.', 'Gaji berhasil dibatalkan.',
'admin.finance.payroll-periods.show', 'admin.finance.payroll-periods.show',
parameters: ['payroll_period' => $payroll->payroll_period_id] parameters: ['payroll_period' => $payroll->payroll_period_id]

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\HR; namespace App\Http\Controllers\Admin\HR;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\HR\EmployeeRequest; use App\Http\Requests\Admin\HR\EmployeeRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\User; use App\Models\User;
use App\Services\Admin\HR\EmployeeService; use App\Services\Admin\HR\EmployeeService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\HR; namespace App\Http\Controllers\Admin\HR;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\HR\LeaveRequestRequest; use App\Http\Requests\Admin\HR\LeaveRequestRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Services\Admin\HR\LeaveRequestService; use App\Services\Admin\HR\LeaveRequestService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\Master; namespace App\Http\Controllers\Admin\Master;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\Master\CategoryRequest; use App\Http\Requests\Admin\Master\CategoryRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Category; use App\Models\Category;
use App\Services\Admin\Master\CategoryService; use App\Services\Admin\Master\CategoryService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\Master; namespace App\Http\Controllers\Admin\Master;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\Master\CustomerRequest; use App\Http\Requests\Admin\Master\CustomerRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Customer; use App\Models\Customer;
use App\Services\Admin\Master\CustomerService; use App\Services\Admin\Master\CustomerService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;

View File

@ -2,10 +2,9 @@
namespace App\Http\Controllers\Admin\Master; namespace App\Http\Controllers\Admin\Master;
use App\Enums\ProductStatus;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\Master\ProductRequest; use App\Http\Requests\Admin\Master\ProductRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Product; use App\Models\Product;
use App\Services\Admin\Master\CategoryService; use App\Services\Admin\Master\CategoryService;
use App\Services\Admin\Master\ProductService; use App\Services\Admin\Master\ProductService;
@ -41,7 +40,7 @@ public function create(): Response
public function store(ProductRequest $request): RedirectResponse public function store(ProductRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn() => $this->service->create($request->validated()), fn () => $this->service->create($request->validated()),
'Produk berhasil ditambahkan.', 'Produk berhasil ditambahkan.',
'admin.master.products.index', 'admin.master.products.index',
'admin.master.products.create' 'admin.master.products.create'
@ -59,7 +58,7 @@ public function edit(Product $product): Response
public function update(ProductRequest $request, Product $product): RedirectResponse public function update(ProductRequest $request, Product $product): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn() => $this->service->update($product, $request->validated()), fn () => $this->service->update($product, $request->validated()),
'Produk berhasil diperbarui.', 'Produk berhasil diperbarui.',
'admin.master.products.index', 'admin.master.products.index',
'admin.master.products.edit', 'admin.master.products.edit',
@ -70,7 +69,7 @@ public function update(ProductRequest $request, Product $product): RedirectRespo
public function destroy(Product $product): RedirectResponse public function destroy(Product $product): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn() => $this->service->delete($product), fn () => $this->service->delete($product),
'Produk berhasil dihapus.', 'Produk berhasil dihapus.',
'admin.master.products.index' 'admin.master.products.index'
); );

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin\Master; namespace App\Http\Controllers\Admin\Master;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\Master\SupplierRequest; use App\Http\Requests\Admin\Master\SupplierRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Supplier; use App\Models\Supplier;
use App\Services\Admin\Master\SupplierService; use App\Services\Admin\Master\SupplierService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;

View File

@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Http\Requests\Admin\RoleRequest; use App\Http\Requests\Admin\RoleRequest;
use App\Http\Requests\PaginatedRequest;
use App\Services\Admin\Settings\RoleService; use App\Services\Admin\Settings\RoleService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;

View File

@ -13,13 +13,16 @@ protected function handleAction(callable $action, string $successMessage, string
try { try {
$action(); $action();
Inertia::flash('toast', ['type' => 'success', 'message' => $successMessage]); Inertia::flash('toast', ['type' => 'success', 'message' => $successMessage]);
return to_route($redirectRoute, $parameters); return to_route($redirectRoute, $parameters);
} catch (ValidationException $e) { } catch (ValidationException $e) {
$firstError = collect($e->errors())->flatten()->first(); $firstError = collect($e->errors())->flatten()->first();
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']); Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);
return to_route($errorRoute ?? $redirectRoute, $parameters); return to_route($errorRoute ?? $redirectRoute, $parameters);
} catch (\Exception $e) { } catch (\Exception $e) {
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]); Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
return to_route($errorRoute ?? $redirectRoute, $parameters); return to_route($errorRoute ?? $redirectRoute, $parameters);
} }
} }

View File

@ -65,7 +65,7 @@ public function rules(): array
'variants.*.reject_stock' => ['required', 'integer', 'min:0'], 'variants.*.reject_stock' => ['required', 'integer', 'min:0'],
'variants.*.retail_stock' => ['required', 'integer', 'min:0'], 'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
'variants.*.photo_key' => ['required', 'string', 'max:500'], 'variants.*.photo_key' => ['required', 'string', 'max:500'],
'variants.*.prices' => ['required_if:use_same_price,false', 'nullable', 'array', ...(!$useSamePrice ? ['size:9'] : [])], 'variants.*.prices' => ['required_if:use_same_price,false', 'nullable', 'array', ...(! $useSamePrice ? ['size:9'] : [])],
'variants.*.prices.*.id' => ['nullable', 'integer'], 'variants.*.prices.*.id' => ['nullable', 'integer'],
'variants.*.prices.*.type' => ['required_if:use_same_price,false', 'nullable', Rule::in(PriceType::values())], 'variants.*.prices.*.type' => ['required_if:use_same_price,false', 'nullable', Rule::in(PriceType::values())],
'variants.*.prices.*.price' => ['required_if:use_same_price,false', 'nullable', 'integer', 'min:0'], 'variants.*.prices.*.price' => ['required_if:use_same_price,false', 'nullable', 'integer', 'min:0'],

View File

@ -11,7 +11,6 @@
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])] #[Guarded(['id'])]
#[Appends(['type_label'])] #[Appends(['type_label'])]

View File

@ -14,7 +14,7 @@
#[Guarded(['id'])] #[Guarded(['id'])]
class ProductVariant extends Model implements HasMedia class ProductVariant extends Model implements HasMedia
{ {
use HasFactory, SoftDeletes, InteractsWithMedia; use HasFactory, InteractsWithMedia, SoftDeletes;
public function orderItems(): HasMany public function orderItems(): HasMany
{ {

View File

@ -2,10 +2,9 @@
namespace App\Services\Admin\Master; namespace App\Services\Admin\Master;
use App\Enums\PriceType;
use App\Models\Product; use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\ProductPrice; use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@ -123,6 +122,7 @@ public function getForEdit(Product $product): array
$variants = $product->productVariants->map(function (ProductVariant $variant) { $variants = $product->productVariants->map(function (ProductVariant $variant) {
$media = $variant->getMedia('photos')->first(); $media = $variant->getMedia('photos')->first();
return [ return [
'id' => $variant->id, 'id' => $variant->id,
'name' => $variant->name, 'name' => $variant->name,

View File

@ -1,9 +1,9 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @import 'tw-animate-css';
@import "tw-animate-css"; @import 'tw-animate-css';
@import "shadcn/tailwind.css"; @import 'shadcn/tailwind.css';
@import "@fontsource-variable/inter"; @import '@fontsource-variable/inter';
@source '../views'; @source '../views';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@ -65,137 +65,74 @@ @theme {
} }
:root { :root {
--background: --background: oklch(1 0 0);
oklch(1 0 0); --foreground: oklch(0.145 0 0);
--foreground: --card: oklch(1 0 0);
oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--card: --popover: oklch(1 0 0);
oklch(1 0 0); --popover-foreground: oklch(0.145 0 0);
--card-foreground: --primary: oklch(0.555 0.163 48.998);
oklch(0.145 0 0); --primary-foreground: oklch(0.987 0.022 95.277);
--popover: --secondary: oklch(0.967 0.001 286.375);
oklch(1 0 0); --secondary-foreground: oklch(0.21 0.006 285.885);
--popover-foreground: --muted: oklch(0.97 0 0);
oklch(0.145 0 0); --muted-foreground: oklch(0.556 0 0);
--primary: --accent: oklch(0.97 0 0);
oklch(0.555 0.163 48.998); --accent-foreground: oklch(0.205 0 0);
--primary-foreground: --destructive: oklch(0.577 0.245 27.325);
oklch(0.987 0.022 95.277);
--secondary:
oklch(0.967 0.001 286.375);
--secondary-foreground:
oklch(0.21 0.006 285.885);
--muted:
oklch(0.97 0 0);
--muted-foreground:
oklch(0.556 0 0);
--accent:
oklch(0.97 0 0);
--accent-foreground:
oklch(0.205 0 0);
--destructive:
oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.577 0.245 27.325);
--border: --border: oklch(0.922 0 0);
oklch(0.922 0 0); --input: oklch(0.922 0 0);
--input: --ring: oklch(0.708 0 0);
oklch(0.922 0 0); --chart-1: oklch(0.879 0.169 91.605);
--ring: --chart-2: oklch(0.769 0.188 70.08);
oklch(0.708 0 0); --chart-3: oklch(0.666 0.179 58.318);
--chart-1: --chart-4: oklch(0.555 0.163 48.998);
oklch(0.879 0.169 91.605); --chart-5: oklch(0.473 0.137 46.201);
--chart-2: --radius: 0.625rem;
oklch(0.769 0.188 70.08); --sidebar: oklch(0.985 0 0);
--chart-3: --sidebar-foreground: oklch(0.145 0 0);
oklch(0.666 0.179 58.318); --sidebar-primary: oklch(0.666 0.179 58.318);
--chart-4: --sidebar-primary-foreground: oklch(0.987 0.022 95.277);
oklch(0.555 0.163 48.998); --sidebar-accent: oklch(0.97 0 0);
--chart-5: --sidebar-accent-foreground: oklch(0.205 0 0);
oklch(0.473 0.137 46.201); --sidebar-border: oklch(0.922 0 0);
--radius: --sidebar-ring: oklch(0.708 0 0);
0.625rem;
--sidebar:
oklch(0.985 0 0);
--sidebar-foreground:
oklch(0.145 0 0);
--sidebar-primary:
oklch(0.666 0.179 58.318);
--sidebar-primary-foreground:
oklch(0.987 0.022 95.277);
--sidebar-accent:
oklch(0.97 0 0);
--sidebar-accent-foreground:
oklch(0.205 0 0);
--sidebar-border:
oklch(0.922 0 0);
--sidebar-ring:
oklch(0.708 0 0);
} }
.dark { .dark {
--background: --background: oklch(0.145 0 0);
oklch(0.145 0 0); --foreground: oklch(0.985 0 0);
--foreground: --card: oklch(0.205 0 0);
oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--card: --popover: oklch(0.205 0 0);
oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0);
--card-foreground: --primary: oklch(0.473 0.137 46.201);
oklch(0.985 0 0); --primary-foreground: oklch(0.987 0.022 95.277);
--popover: --secondary: oklch(0.274 0.006 286.033);
oklch(0.205 0 0); --secondary-foreground: oklch(0.985 0 0);
--popover-foreground: --muted: oklch(0.269 0 0);
oklch(0.985 0 0); --muted-foreground: oklch(0.708 0 0);
--primary: --accent: oklch(0.269 0 0);
oklch(0.473 0.137 46.201); --accent-foreground: oklch(0.985 0 0);
--primary-foreground: --destructive: oklch(0.704 0.191 22.216);
oklch(0.987 0.022 95.277);
--secondary:
oklch(0.274 0.006 286.033);
--secondary-foreground:
oklch(0.985 0 0);
--muted:
oklch(0.269 0 0);
--muted-foreground:
oklch(0.708 0 0);
--accent:
oklch(0.269 0 0);
--accent-foreground:
oklch(0.985 0 0);
--destructive:
oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.637 0.237 25.331); --destructive-foreground: oklch(0.637 0.237 25.331);
--border: --border: oklch(1 0 0 / 10%);
oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%);
--input: --ring: oklch(0.556 0 0);
oklch(1 0 0 / 15%); --chart-1: oklch(0.879 0.169 91.605);
--ring: --chart-2: oklch(0.769 0.188 70.08);
oklch(0.556 0 0); --chart-3: oklch(0.666 0.179 58.318);
--chart-1: --chart-4: oklch(0.555 0.163 48.998);
oklch(0.879 0.169 91.605); --chart-5: oklch(0.473 0.137 46.201);
--chart-2: --sidebar: oklch(0.205 0 0);
oklch(0.769 0.188 70.08); --sidebar-foreground: oklch(0.985 0 0);
--chart-3: --sidebar-primary: oklch(0.769 0.188 70.08);
oklch(0.666 0.179 58.318); --sidebar-primary-foreground: oklch(0.279 0.077 45.635);
--chart-4: --sidebar-accent: oklch(0.269 0 0);
oklch(0.555 0.163 48.998); --sidebar-accent-foreground: oklch(0.985 0 0);
--chart-5: --sidebar-border: oklch(1 0 0 / 10%);
oklch(0.473 0.137 46.201); --sidebar-ring: oklch(0.556 0 0);
--sidebar:
oklch(0.205 0 0);
--sidebar-foreground:
oklch(0.985 0 0);
--sidebar-primary:
oklch(0.769 0.188 70.08);
--sidebar-primary-foreground:
oklch(0.279 0.077 45.635);
--sidebar-accent:
oklch(0.269 0 0);
--sidebar-accent-foreground:
oklch(0.985 0 0);
--sidebar-border:
oklch(1 0 0 / 10%);
--sidebar-ring:
oklch(0.556 0 0);
} }
@layer base { @layer base {
@ -206,90 +143,50 @@ @layer base {
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
html { html {
@apply font-sans; @apply font-sans;
} }
} }
@theme inline { @theme inline {
--font-heading: --font-heading: var(--font-sans);
var(--font-sans); --font-sans: 'Inter Variable', sans-serif;
--font-sans: --color-sidebar-ring: var(--sidebar-ring);
'Inter Variable', sans-serif; --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
var(--sidebar-ring); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-border: --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
var(--sidebar-border); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-accent-foreground: --color-sidebar-foreground: var(--sidebar-foreground);
var(--sidebar-accent-foreground); --color-sidebar: var(--sidebar);
--color-sidebar-accent: --color-chart-5: var(--chart-5);
var(--sidebar-accent); --color-chart-4: var(--chart-4);
--color-sidebar-primary-foreground: --color-chart-3: var(--chart-3);
var(--sidebar-primary-foreground); --color-chart-2: var(--chart-2);
--color-sidebar-primary: --color-chart-1: var(--chart-1);
var(--sidebar-primary); --color-ring: var(--ring);
--color-sidebar-foreground: --color-input: var(--input);
var(--sidebar-foreground); --color-border: var(--border);
--color-sidebar: --color-destructive: var(--destructive);
var(--sidebar); --color-accent-foreground: var(--accent-foreground);
--color-chart-5: --color-accent: var(--accent);
var(--chart-5); --color-muted-foreground: var(--muted-foreground);
--color-chart-4: --color-muted: var(--muted);
var(--chart-4); --color-secondary-foreground: var(--secondary-foreground);
--color-chart-3: --color-secondary: var(--secondary);
var(--chart-3); --color-primary-foreground: var(--primary-foreground);
--color-chart-2: --color-primary: var(--primary);
var(--chart-2); --color-popover-foreground: var(--popover-foreground);
--color-chart-1: --color-popover: var(--popover);
var(--chart-1); --color-card-foreground: var(--card-foreground);
--color-ring: --color-card: var(--card);
var(--ring); --color-foreground: var(--foreground);
--color-input: --color-background: var(--background);
var(--input); --radius-sm: calc(var(--radius) * 0.6);
--color-border: --radius-md: calc(var(--radius) * 0.8);
var(--border); --radius-lg: var(--radius);
--color-destructive: --radius-xl: calc(var(--radius) * 1.4);
var(--destructive); --radius-2xl: calc(var(--radius) * 1.8);
--color-accent-foreground: --radius-3xl: calc(var(--radius) * 2.2);
var(--accent-foreground); --radius-4xl: calc(var(--radius) * 2.6);
--color-accent: }
var(--accent);
--color-muted-foreground:
var(--muted-foreground);
--color-muted:
var(--muted);
--color-secondary-foreground:
var(--secondary-foreground);
--color-secondary:
var(--secondary);
--color-primary-foreground:
var(--primary-foreground);
--color-primary:
var(--primary);
--color-popover-foreground:
var(--popover-foreground);
--color-popover:
var(--popover);
--color-card-foreground:
var(--card-foreground);
--color-card:
var(--card);
--color-foreground:
var(--foreground);
--color-background:
var(--background);
--radius-sm:
calc(var(--radius) * 0.6);
--radius-md:
calc(var(--radius) * 0.8);
--radius-lg:
var(--radius);
--radius-xl:
calc(var(--radius) * 1.4);
--radius-2xl:
calc(var(--radius) * 1.8);
--radius-3xl:
calc(var(--radius) * 2.2);
--radius-4xl:
calc(var(--radius) * 2.6);
}

View File

@ -2,6 +2,8 @@ import type { ImgHTMLAttributes } from 'react';
import logo from '../../../public/assets/logo.png'; import logo from '../../../public/assets/logo.png';
export default function AppLogoIcon(props: ImgHTMLAttributes<HTMLImageElement>) { export default function AppLogoIcon(
props: ImgHTMLAttributes<HTMLImageElement>,
) {
return <img src={logo} alt="Logo" {...props} />; return <img src={logo} alt="Logo" {...props} />;
} }

View File

@ -106,7 +106,10 @@ function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {
<SidebarMenu> <SidebarMenu>
{items.map((item) => ( {items.map((item) => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild tooltip={{ children: item.title }}> <SidebarMenuButton
asChild
tooltip={{ children: item.title }}
>
<Link href={item.href} prefetch> <Link href={item.href} prefetch>
<item.icon /> <item.icon />
<span>{item.title}</span> <span>{item.title}</span>
@ -151,7 +154,11 @@ export function AppSidebar() {
<SidebarGroup> <SidebarGroup>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild isActive={isCurrentUrl('/dashboard')} tooltip={{ children: dasborItem.title }}> <SidebarMenuButton
asChild
isActive={isCurrentUrl('/dashboard')}
tooltip={{ children: dasborItem.title }}
>
<Link href={dasborItem.href} prefetch> <Link href={dasborItem.href} prefetch>
<dasborItem.icon /> <dasborItem.icon />
<span>{dasborItem.title}</span> <span>{dasborItem.title}</span>
@ -164,7 +171,10 @@ export function AppSidebar() {
<SidebarGroup> <SidebarGroup>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild tooltip={{ children: analisaItem.title }}> <SidebarMenuButton
asChild
tooltip={{ children: analisaItem.title }}
>
<Link href={analisaItem.href} prefetch> <Link href={analisaItem.href} prefetch>
<analisaItem.icon /> <analisaItem.icon />
<span>{analisaItem.title}</span> <span>{analisaItem.title}</span>

View File

@ -25,7 +25,9 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
} }
setError(null); setError(null);
} catch { } catch {
setError('Tidak dapat mengakses kamera. Pastikan izin kamera diberikan.'); setError(
'Tidak dapat mengakses kamera. Pastikan izin kamera diberikan.',
);
} }
}, []); }, []);
@ -81,14 +83,24 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
{error ? ( {error ? (
<div className="flex flex-col items-center gap-4 py-8"> <div className="flex flex-col items-center gap-4 py-8">
<p className="text-center text-sm text-muted-foreground">{error}</p> <p className="text-center text-sm text-muted-foreground">
{error}
</p>
<Button onClick={startCamera}>Coba Lagi</Button> <Button onClick={startCamera}>Coba Lagi</Button>
</div> </div>
) : capturedImage ? ( ) : capturedImage ? (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<img src={capturedImage} alt="Captured" className="w-full rounded-lg" /> <img
src={capturedImage}
alt="Captured"
className="w-full rounded-lg"
/>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={retake}> <Button
variant="outline"
className="flex-1"
onClick={retake}
>
<RotateCcw className="mr-2 h-4 w-4" /> <RotateCcw className="mr-2 h-4 w-4" />
Ulangi Ulangi
</Button> </Button>

View File

@ -4,7 +4,7 @@ import {
KeyboardSensor, KeyboardSensor,
PointerSensor, PointerSensor,
useSensor, useSensor,
useSensors useSensors,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { import {
SortableContext, SortableContext,
@ -17,9 +17,15 @@ import {
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getExpandedRowModel, getExpandedRowModel,
useReactTable useReactTable,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, GripVertical } from 'lucide-react'; import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
GripVertical,
} from 'lucide-react';
import * as React from 'react'; import * as React from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -167,8 +173,8 @@ export function DataTable<TData, TValue>({
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const [expanded, setExpanded] = React.useState<ExpandedState>(() => { const [expanded, setExpanded] = React.useState<ExpandedState>(() => {
if (!defaultExpanded || !data.length) { if (!defaultExpanded || !data.length) {
return {}; return {};
} }
const initial: Record<string, boolean> = {}; const initial: Record<string, boolean> = {};
data.forEach((item, index) => { data.forEach((item, index) => {
@ -194,17 +200,17 @@ return {};
const visibleColumns = isSortable const visibleColumns = isSortable
? [ ? [
{ {
id: 'drag', id: 'drag',
header: '', header: '',
cell: () => <DragHandleTrigger />, cell: () => <DragHandleTrigger />,
meta: { meta: {
className: 'w-[40px]', className: 'w-[40px]',
headerClassName: 'w-[40px]', headerClassName: 'w-[40px]',
}, },
} as ColumnDef<TData, TValue>, } as ColumnDef<TData, TValue>,
...columns, ...columns,
] ]
: columns; : columns;
const sensors = useSensors( const sensors = useSensors(
@ -261,8 +267,8 @@ return {};
function handleSort(columnId: string) { function handleSort(columnId: string) {
if (!onSortChange) { if (!onSortChange) {
return; return;
} }
const newDirection = const newDirection =
currentSort?.column === columnId && currentSort?.direction === 'asc' currentSort?.column === columnId && currentSort?.direction === 'asc'
@ -282,25 +288,31 @@ return;
<Input <Input
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
value={localSearch} value={localSearch}
onChange={(event) => handleSearchChange(event.target.value)} onChange={(event) =>
handleSearchChange(event.target.value)
}
className="max-w-sm" className="max-w-sm"
/> />
)} )}
{toolbar} {toolbar}
<div className="flex items-center gap-2 ml-auto"> <div className="ml-auto flex items-center gap-2">
{isServerMode && onPerPageChange && ( {isServerMode && onPerPageChange && (
<Select <Select
value={String(pagination?.per_page ?? 25)} value={String(pagination?.per_page ?? 25)}
onValueChange={(value) => onPerPageChange(Number(value))} onValueChange={(value) =>
onPerPageChange(Number(value))
}
> >
<SelectTrigger className="h-8 w-[70px]"> <SelectTrigger className="h-8 w-[70px]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="25">25</SelectItem> <SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem> <SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem> <SelectItem value="100">100</SelectItem>
<SelectItem value="999999">Semua</SelectItem> <SelectItem value="999999">
Semua
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
@ -320,18 +332,18 @@ return;
( (
header.column.columnDef header.column.columnDef
.meta as { .meta as {
headerClassName?: string; headerClassName?: string;
} }
)?.headerClassName )?.headerClassName
} }
> >
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
header.column.columnDef header.column.columnDef
.header, .header,
header.getContext(), header.getContext(),
)} )}
</TableHead> </TableHead>
))} ))}
</TableRow> </TableRow>
@ -374,8 +386,8 @@ return;
.column .column
.columnDef .columnDef
.meta as { .meta as {
className?: string; className?: string;
} }
) )
?.className ?.className
} }
@ -394,55 +406,58 @@ return;
</SortableContext> </SortableContext>
</DndContext> </DndContext>
) : ( ) : (
table table.getRowModel().rows.map((row) => (
.getRowModel() <React.Fragment key={row.id}>
.rows.map((row) => ( <TableRow
<React.Fragment key={row.id}> data-state={
<TableRow row.getIsSelected() &&
data-state={ 'selected'
row.getIsSelected() && }
'selected' >
} {row
> .getVisibleCells()
{row .map((cell) => (
.getVisibleCells() <TableCell
.map((cell) => ( key={cell.id}
<TableCell className={
key={cell.id} (
className={
(
cell
.column
.columnDef
.meta as {
className?: string;
}
)?.className
}
>
{flexRender(
cell.column cell.column
.columnDef .columnDef
.cell, .meta as {
cell.getContext(), className?: string;
)} }
</TableCell> )?.className
))} }
</TableRow> >
{renderSubRow && row.getIsExpanded() && ( {flexRender(
cell.column
.columnDef
.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
{renderSubRow &&
row.getIsExpanded() && (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={visibleColumns.length} colSpan={
visibleColumns.length
}
className="bg-muted/50 p-0" className="bg-muted/50 p-0"
> >
<div className="p-4"> <div className="p-4">
{renderSubRow(row, localSearch)} {renderSubRow(
row,
localSearch,
)}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</React.Fragment> </React.Fragment>
)) ))
) )
) : ( ) : (
<TableRow> <TableRow>
@ -530,7 +545,9 @@ return;
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => table.setPageIndex(table.getPageCount() - 1)} onClick={() =>
table.setPageIndex(table.getPageCount() - 1)
}
disabled={!table.getCanNextPage()} disabled={!table.getCanNextPage()}
> >
<ChevronsRight className="h-4 w-4" /> <ChevronsRight className="h-4 w-4" />

View File

@ -1,33 +1,33 @@
import * as React from "react" import * as React from 'react';
import { format } from "date-fns" import { format } from 'date-fns';
import { id } from "date-fns/locale" import { id } from 'date-fns/locale';
import { CalendarIcon } from "lucide-react" import { CalendarIcon } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { Button } from "@/components/ui/button" import { Button } from '@/components/ui/button';
import { Calendar } from "@/components/ui/calendar" import { Calendar } from '@/components/ui/calendar';
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover" } from '@/components/ui/popover';
interface DatePickerProps { interface DatePickerProps {
value?: Date | string | null value?: Date | string | null;
onChange?: (date: Date | undefined) => void onChange?: (date: Date | undefined) => void;
placeholder?: string placeholder?: string;
disabled?: boolean disabled?: boolean;
className?: string className?: string;
name?: string name?: string;
id?: string id?: string;
min?: Date min?: Date;
max?: Date max?: Date;
} }
function DatePicker({ function DatePicker({
value, value,
onChange, onChange,
placeholder = "Pilih tanggal", placeholder = 'Pilih tanggal',
disabled = false, disabled = false,
className, className,
name, name,
@ -35,18 +35,18 @@ function DatePicker({
min, min,
max, max,
}: DatePickerProps) { }: DatePickerProps) {
const [open, setOpen] = React.useState(false) const [open, setOpen] = React.useState(false);
const date = React.useMemo(() => { const date = React.useMemo(() => {
if (!value) return undefined if (!value) return undefined;
if (value instanceof Date) return value if (value instanceof Date) return value;
return new Date(value) return new Date(value);
}, [value]) }, [value]);
const formattedDate = React.useMemo(() => { const formattedDate = React.useMemo(() => {
if (!date) return "" if (!date) return '';
return format(date, "dd MMM yyyy", { locale: id }) return format(date, 'dd MMM yyyy', { locale: id });
}, [date]) }, [date]);
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
@ -56,9 +56,9 @@ function DatePicker({
variant="outline" variant="outline"
disabled={disabled} disabled={disabled}
className={cn( className={cn(
"w-full justify-start text-left font-normal", 'w-full justify-start text-left font-normal',
!date && "text-muted-foreground", !date && 'text-muted-foreground',
className className,
)} )}
> >
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
@ -70,22 +70,26 @@ function DatePicker({
mode="single" mode="single"
selected={date} selected={date}
onSelect={(selectedDate) => { onSelect={(selectedDate) => {
onChange?.(selectedDate) onChange?.(selectedDate);
setOpen(false) setOpen(false);
}} }}
disabled={(date) => { disabled={(date) => {
if (min && date < min) return true if (min && date < min) return true;
if (max && date > max) return true if (max && date > max) return true;
return false return false;
}} }}
initialFocus initialFocus
/> />
</PopoverContent> </PopoverContent>
{name && ( {name && (
<input type="hidden" name={name} value={date ? format(date, "yyyy-MM-dd") : ""} /> <input
type="hidden"
name={name}
value={date ? format(date, 'yyyy-MM-dd') : ''}
/>
)} )}
</Popover> </Popover>
) );
} }
export { DatePicker } export { DatePicker };

View File

@ -49,7 +49,12 @@ function acceptToLabels(accept: string): string[] {
return accept return accept
.split(',') .split(',')
.map((mime) => mimeMap[mime.trim()] || mime.trim().split('/').pop()?.toUpperCase() || 'File') .map(
(mime) =>
mimeMap[mime.trim()] ||
mime.trim().split('/').pop()?.toUpperCase() ||
'File',
)
.filter((v, i, a) => a.indexOf(v) === i); .filter((v, i, a) => a.indexOf(v) === i);
} }
@ -60,7 +65,16 @@ function formatMaxSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(0)}MB`; return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
} }
export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image/png,image/webp,image/gif', maxSize = 10 * 1024 * 1024, onUploadingChange, existingUrl, onFileMeta }: FileUploadProps) { export function FileUpload({
value,
onChange,
folder,
accept = 'image/jpeg,image/png,image/webp,image/gif',
maxSize = 10 * 1024 * 1024,
onUploadingChange,
existingUrl,
onFileMeta,
}: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const uploadId = useId(); const uploadId = useId();
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@ -96,13 +110,15 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
onChange(key); onChange(key);
onFileMeta?.({ size: file.size, type: file.type }); onFileMeta?.({ size: file.size, type: file.type });
} catch (err) { } catch (err) {
const message = err instanceof UploadError ? err.message : 'Gagal mengunggah file.'; const message =
err instanceof UploadError
? err.message
: 'Gagal mengunggah file.';
setError(message); setError(message);
setPreview(null); setPreview(null);
setFileName(null); setFileName(null);
setFileSize(null); setFileSize(null);
onFileMeta?.(null); onFileMeta?.(null);
} finally { } finally {
setUploading(false); setUploading(false);
@ -126,7 +142,13 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
} }
} }
const state = uploading ? 'uploading' : error ? 'error' : value ? 'done' : 'idle'; const state = uploading
? 'uploading'
: error
? 'error'
: value
? 'done'
: 'idle';
return ( return (
<> <>
@ -139,13 +161,21 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
id={uploadId} id={uploadId}
/> />
<Attachment state={state} orientation="horizontal" className='w-full'> <Attachment
state={state}
orientation="horizontal"
className="w-full"
>
<AttachmentTrigger <AttachmentTrigger
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
aria-label={value ? 'Ganti file' : 'Pilih file untuk diunggah'} aria-label={
value ? 'Ganti file' : 'Pilih file untuk diunggah'
}
/> />
<AttachmentMedia variant={preview || existingUrl ? 'image' : 'icon'}> <AttachmentMedia
variant={preview || existingUrl ? 'image' : 'icon'}
>
{preview ? ( {preview ? (
<img src={preview} alt={fileName ?? 'Preview'} /> <img src={preview} alt={fileName ?? 'Preview'} />
) : existingUrl && value ? ( ) : existingUrl && value ? (
@ -162,18 +192,24 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
<> <>
<AttachmentTitle>{fileName}</AttachmentTitle> <AttachmentTitle>{fileName}</AttachmentTitle>
<AttachmentDescription> <AttachmentDescription>
{fileSize ? formatFileSize(fileSize) : 'Terupload'} {fileSize
? formatFileSize(fileSize)
: 'Terupload'}
</AttachmentDescription> </AttachmentDescription>
</> </>
) : uploading ? ( ) : uploading ? (
<> <>
<AttachmentTitle>Mengunggah...</AttachmentTitle> <AttachmentTitle>Mengunggah...</AttachmentTitle>
<AttachmentDescription>Memproses file</AttachmentDescription> <AttachmentDescription>
Memproses file
</AttachmentDescription>
</> </>
) : error ? ( ) : error ? (
<> <>
<AttachmentTitle>Gagal</AttachmentTitle> <AttachmentTitle>Gagal</AttachmentTitle>
<AttachmentDescription>{error}</AttachmentDescription> <AttachmentDescription>
{error}
</AttachmentDescription>
</> </>
) : ( ) : (
<> <>
@ -187,7 +223,10 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
{value && ( {value && (
<AttachmentActions> <AttachmentActions>
<AttachmentAction aria-label="Hapus file" onClick={handleRemove}> <AttachmentAction
aria-label="Hapus file"
onClick={handleRemove}
>
<X /> <X />
</AttachmentAction> </AttachmentAction>
</AttachmentActions> </AttachmentActions>

View File

@ -1,4 +1,9 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
type ImagePreviewModalProps = { type ImagePreviewModalProps = {
open: boolean; open: boolean;
@ -8,7 +13,13 @@ type ImagePreviewModalProps = {
alt?: string; alt?: string;
}; };
export function ImagePreviewModal({ open, onOpenChange, src, title, alt = 'Preview' }: ImagePreviewModalProps) { export function ImagePreviewModal({
open,
onOpenChange,
src,
title,
alt = 'Preview',
}: ImagePreviewModalProps) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton> <DialogContent showCloseButton>
@ -21,7 +32,7 @@ export function ImagePreviewModal({ open, onOpenChange, src, title, alt = 'Previ
<img <img
src={src} src={src}
alt={alt} alt={alt}
className="w-full rounded-lg object-contain max-h-[80vh]" className="max-h-[80vh] w-full rounded-lg object-contain"
/> />
)} )}
</DialogContent> </DialogContent>

View File

@ -9,7 +9,12 @@ interface LocationMapProps {
zoom?: number; zoom?: number;
} }
export function LocationMap({ latitude, longitude, height = '250px', zoom = 15 }: LocationMapProps) { export function LocationMap({
latitude,
longitude,
height = '250px',
zoom = 15,
}: LocationMapProps) {
const mapRef = useRef<HTMLDivElement>(null); const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null); const mapInstanceRef = useRef<L.Map | null>(null);
@ -26,7 +31,8 @@ export function LocationMap({ latitude, longitude, height = '250px', zoom = 15 }
L.control.zoom({ position: 'topright' }).addTo(map); L.control.zoom({ position: 'topright' }).addTo(map);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map); }).addTo(map);
const icon = L.divIcon({ const icon = L.divIcon({
@ -46,5 +52,11 @@ export function LocationMap({ latitude, longitude, height = '250px', zoom = 15 }
}; };
}, [latitude, longitude, zoom]); }, [latitude, longitude, zoom]);
return <div ref={mapRef} style={{ height, width: '100%' }} className="rounded-lg" />; return (
<div
ref={mapRef}
style={{ height, width: '100%' }}
className="rounded-lg"
/>
);
} }

View File

@ -19,17 +19,14 @@ export function NavUser() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
data-test="header-user-menu-button" data-test="header-user-menu-button"
> >
<UserInfo user={auth.user} /> <UserInfo user={auth.user} />
<ChevronsUpDown className="ml-auto size-4" /> <ChevronsUpDown className="ml-auto size-4" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent className="w-56 rounded-lg" align="end">
className="w-56 rounded-lg"
align="end"
>
<UserMenuContent user={auth.user} /> <UserMenuContent user={auth.user} />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@ -12,7 +12,8 @@ export function PWAUpdateToast() {
const handleUpdateAvailable = () => { const handleUpdateAvailable = () => {
toast.info('Update tersedia!', { toast.info('Update tersedia!', {
description: 'Versi baru aplikasi telah tersedia. Klik untuk memperbarui.', description:
'Versi baru aplikasi telah tersedia. Klik untuk memperbarui.',
duration: Infinity, duration: Infinity,
action: { action: {
label: 'Update', label: 'Update',
@ -28,7 +29,10 @@ export function PWAUpdateToast() {
return () => { return () => {
window.removeEventListener('sw-offline-ready', handleOfflineReady); window.removeEventListener('sw-offline-ready', handleOfflineReady);
window.removeEventListener('sw-update-available', handleUpdateAvailable); window.removeEventListener(
'sw-update-available',
handleUpdateAvailable,
);
}; };
}, []); }, []);

View File

@ -40,7 +40,9 @@ export function RupiahInput({
className, className,
}: RupiahInputProps) { }: RupiahInputProps) {
const isControlled = value !== undefined; const isControlled = value !== undefined;
const [displayValue, setDisplayValue] = useState(formatRupiah(isControlled ? value : defaultValue)); const [displayValue, setDisplayValue] = useState(
formatRupiah(isControlled ? value : defaultValue),
);
const lastValidRef = useRef(isControlled ? value : defaultValue); const lastValidRef = useRef(isControlled ? value : defaultValue);
if (isControlled) { if (isControlled) {

View File

@ -18,27 +18,54 @@ export function useFileUpload() {
preview: null, preview: null,
}); });
const upload = useCallback(async (file: File, folder?: string): Promise<string | null> => { const upload = useCallback(
setState({ uploading: true, progress: 0, error: null, key: null, preview: null }); async (file: File, folder?: string): Promise<string | null> => {
setState({
uploading: true,
progress: 0,
error: null,
key: null,
preview: null,
});
try { try {
const preview = URL.createObjectURL(file); const preview = URL.createObjectURL(file);
setState((prev) => ({ ...prev, preview, progress: 30 })); setState((prev) => ({ ...prev, preview, progress: 30 }));
const key = await uploadFile(file, folder); const key = await uploadFile(file, folder);
setState((prev) => ({ ...prev, key, uploading: false, progress: 100 })); setState((prev) => ({
...prev,
key,
uploading: false,
progress: 100,
}));
return key; return key;
} catch (err) { } catch (err) {
const message = err instanceof UploadError ? err.message : 'Terjadi kesalahan saat mengunggah file.'; const message =
setState((prev) => ({ ...prev, error: message, uploading: false })); err instanceof UploadError
? err.message
: 'Terjadi kesalahan saat mengunggah file.';
setState((prev) => ({
...prev,
error: message,
uploading: false,
}));
return null; return null;
} }
}, []); },
[],
);
const reset = useCallback(() => { const reset = useCallback(() => {
setState({ uploading: false, progress: 0, error: null, key: null, preview: null }); setState({
uploading: false,
progress: 0,
error: null,
key: null,
preview: null,
});
}, []); }, []);
const setKey = useCallback((key: string | null) => { const setKey = useCallback((key: string | null) => {

View File

@ -9,8 +9,8 @@ function getInitial(name: string): string {
export function useInitials(): GetInitialsFn { export function useInitials(): GetInitialsFn {
return useCallback((fullName: string): string => { return useCallback((fullName: string): string => {
if (!fullName) { if (!fullName) {
return ''; return '';
} }
const names = fullName.trim().split(/\s+/u).filter(Boolean); const names = fullName.trim().split(/\s+/u).filter(Boolean);

View File

@ -1,20 +1,24 @@
import * as React from "react" import * as React from 'react';
const MOBILE_BREAKPOINT = 768 const MOBILE_BREAKPOINT = 768;
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => { React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const mql = window.matchMedia(
const onChange = () => { `(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) );
} const onChange = () => {
mql.addEventListener("change", onChange) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) };
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange) return () => mql.removeEventListener('change', onChange);
}, []) }, []);
return !!isMobile return !!isMobile;
} }

View File

@ -11,7 +11,10 @@ export default function AppSidebarLayout({
return ( return (
<AppShell variant="sidebar"> <AppShell variant="sidebar">
<AppSidebar /> <AppSidebar />
<AppContent variant="sidebar" className="min-h-svh overflow-x-hidden overflow-y-auto"> <AppContent
variant="sidebar"
className="min-h-svh overflow-x-hidden overflow-y-auto"
>
<AppSidebarHeader breadcrumbs={breadcrumbs} /> <AppSidebarHeader breadcrumbs={breadcrumbs} />
{children} {children}
</AppContent> </AppContent>

View File

@ -26,7 +26,11 @@ export default function AuthCardLayout({
className="flex items-center gap-2 self-center font-medium" className="flex items-center gap-2 self-center font-medium"
> >
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<img src="/assets/logo.png" alt="Logo" className="size-40" /> <img
src="/assets/logo.png"
alt="Logo"
className="size-40"
/>
</div> </div>
</Link> </Link>

View File

@ -73,9 +73,7 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
<Separator className="my-6 lg:hidden" /> <Separator className="my-6 lg:hidden" />
<section className="flex-1 space-y-12"> <section className="flex-1 space-y-12">{children}</section>
{children}
</section>
</div> </div>
</div> </div>
); );

View File

@ -15,7 +15,12 @@ export class UploadError extends Error {
} }
function getSessionCookie(): string { function getSessionCookie(): string {
return document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='))?.split('=')[1] ?? ''; return (
document.cookie
.split('; ')
.find((c) => c.startsWith('XSRF-TOKEN='))
?.split('=')[1] ?? ''
);
} }
export async function requestPresignedUrl( export async function requestPresignedUrl(
@ -31,7 +36,11 @@ export async function requestPresignedUrl(
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': decodeURIComponent(getSessionCookie()), 'X-XSRF-TOKEN': decodeURIComponent(getSessionCookie()),
}, },
body: JSON.stringify({ file_name: fileName, mime_type: mimeType, folder }), body: JSON.stringify({
file_name: fileName,
mime_type: mimeType,
folder,
}),
}); });
if (!response.ok) { if (!response.ok) {
@ -59,14 +68,20 @@ export async function uploadToS3(uploadUrl: string, file: File): Promise<void> {
export async function uploadFile(file: File, folder?: string): Promise<string> { export async function uploadFile(file: File, folder?: string): Promise<string> {
if (!ALLOWED_TYPES.includes(file.type)) { if (!ALLOWED_TYPES.includes(file.type)) {
throw new UploadError('Tipe file tidak didukung. Hanya JPG, PNG, WebP, dan GIF yang diizinkan.'); throw new UploadError(
'Tipe file tidak didukung. Hanya JPG, PNG, WebP, dan GIF yang diizinkan.',
);
} }
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
throw new UploadError('Ukuran file melebihi batas 10MB.'); throw new UploadError('Ukuran file melebihi batas 10MB.');
} }
const { upload_url, key } = await requestPresignedUrl(file.name, file.type, folder); const { upload_url, key } = await requestPresignedUrl(
file.name,
file.type,
folder,
);
await uploadToS3(upload_url, file); await uploadToS3(upload_url, file);
return key; return key;

View File

@ -30,9 +30,7 @@ export function createCashAccountColumns(
id: 'no', id: 'no',
header: () => <span className="block text-center">No</span>, header: () => <span className="block text-center">No</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block text-center"> <span className="block text-center">{row.index + 1}</span>
{row.index + 1}
</span>
), ),
meta: { meta: {
className: 'w-[50px] text-center', className: 'w-[50px] text-center',
@ -46,9 +44,7 @@ export function createCashAccountColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Nama</span> <span>Nama</span>
@ -68,9 +64,7 @@ export function createCashAccountColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Saldo</span> <span>Saldo</span>
@ -101,16 +95,12 @@ export function createCashAccountColumns(
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => onClick={() => handleEdit(cashAccount)}
handleEdit(cashAccount)
}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">Edit</TooltipContent>
Edit
</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>

View File

@ -1,5 +1,11 @@
import { Form, Head, router } from '@inertiajs/react'; import { Form, Head, router } from '@inertiajs/react';
import { ArrowDownToLine, ArrowUpFromLine, Filter, Wallet, X } from 'lucide-react'; import {
ArrowDownToLine,
ArrowUpFromLine,
Filter,
Wallet,
X,
} from 'lucide-react';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
@ -18,11 +24,28 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { index as cashAccountIndex, deposit, withdrawal } from '@/routes/admin/finance/cash-accounts'; import {
import { update as updateTransaction, destroy as destroyTransaction } from '@/routes/admin/finance/cash-accounts/transactions'; index as cashAccountIndex,
deposit,
withdrawal,
} from '@/routes/admin/finance/cash-accounts';
import {
update as updateTransaction,
destroy as destroyTransaction,
} from '@/routes/admin/finance/cash-accounts/transactions';
import { createTransactionColumns } from './transaction-columns'; import { createTransactionColumns } from './transaction-columns';
import type { CashTransaction } from './transaction-columns'; import type { CashTransaction } from './transaction-columns';
@ -46,27 +69,47 @@ type Props = {
}; };
}; };
export default function CashAccountIndex({ cashAccount, transactions, filters }: Props) { export default function CashAccountIndex({
cashAccount,
transactions,
filters,
}: Props) {
const [depositOpen, setDepositOpen] = useState(false); const [depositOpen, setDepositOpen] = useState(false);
const [withdrawalOpen, setWithdrawalOpen] = useState(false); const [withdrawalOpen, setWithdrawalOpen] = useState(false);
const [editing, setEditing] = useState<CashTransaction | null>(null); const [editing, setEditing] = useState<CashTransaction | null>(null);
const [deleting, setDeleting] = useState<CashTransaction | null>(null); const [deleting, setDeleting] = useState<CashTransaction | null>(null);
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const [depositReceiptKey, setDepositReceiptKey] = useState<string | null>(null); const [depositReceiptKey, setDepositReceiptKey] = useState<string | null>(
null,
);
const [depositUploading, setDepositUploading] = useState(false); const [depositUploading, setDepositUploading] = useState(false);
const [depositFileMeta, setDepositFileMeta] = useState<{ size: number; type: string } | null>(null); const [depositFileMeta, setDepositFileMeta] = useState<{
size: number;
type: string;
} | null>(null);
const [withdrawalReceiptKey, setWithdrawalReceiptKey] = useState<string | null>(null); const [withdrawalReceiptKey, setWithdrawalReceiptKey] = useState<
string | null
>(null);
const [withdrawalUploading, setWithdrawalUploading] = useState(false); const [withdrawalUploading, setWithdrawalUploading] = useState(false);
const [withdrawalFileMeta, setWithdrawalFileMeta] = useState<{ size: number; type: string } | null>(null); const [withdrawalFileMeta, setWithdrawalFileMeta] = useState<{
size: number;
type: string;
} | null>(null);
const [editReceiptKey, setEditReceiptKey] = useState<string | null>(null); const [editReceiptKey, setEditReceiptKey] = useState<string | null>(null);
const [editUploading, setEditUploading] = useState(false); const [editUploading, setEditUploading] = useState(false);
const [editFileMeta, setEditFileMeta] = useState<{ size: number; type: string } | null>(null); const [editFileMeta, setEditFileMeta] = useState<{
size: number;
type: string;
} | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' }); const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = { const pagination: PaginationState = {
current_page: transactions.current_page, current_page: transactions.current_page,
@ -86,24 +129,32 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
newFilters[key as keyof typeof newFilters] = value; newFilters[key as keyof typeof newFilters] = value;
} }
router.get(cashAccountIndex(), { router.get(
...newFilters, cashAccountIndex(),
page: 1, {
per_page: pagination.per_page, ...newFilters,
search, page: 1,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
function clearFilters() { function clearFilters() {
router.get(cashAccountIndex(), { router.get(
page: 1, cashAccountIndex(),
per_page: pagination.per_page, {
search, page: 1,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
setFilterOpen(false); setFilterOpen(false);
} }
@ -118,49 +169,68 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
} }
function handlePageChange(page: number) { function handlePageChange(page: number) {
router.get(cashAccountIndex(), { router.get(
...filters, cashAccountIndex(),
page, {
per_page: pagination.per_page, ...filters,
search, page,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
function handlePerPageChange(perPage: number) { function handlePerPageChange(perPage: number) {
router.get(cashAccountIndex(), { router.get(
...filters, cashAccountIndex(),
page: 1, {
per_page: perPage, ...filters,
search, page: 1,
sort: sort.column, per_page: perPage,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
const handleSearchChange = useCallback((value: string) => { const handleSearchChange = useCallback(
setSearch(value); (value: string) => {
router.get(cashAccountIndex(), { setSearch(value);
...filters, router.get(
page: 1, cashAccountIndex(),
per_page: pagination.per_page, {
search: value, ...filters,
sort: sort.column, page: 1,
direction: sort.direction, per_page: pagination.per_page,
}, { preserveState: true, replace: true }); search: value,
}, [pagination.per_page, sort, filters]); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort, filters],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') { function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction }); setSort({ column, direction });
router.get(cashAccountIndex(), { router.get(
...filters, cashAccountIndex(),
page: 1, {
per_page: pagination.per_page, ...filters,
search, page: 1,
sort: column, per_page: pagination.per_page,
direction, search,
}, { preserveState: true, replace: true }); sort: column,
direction,
},
{ preserveState: true, replace: true },
);
} }
const columns = createTransactionColumns({ const columns = createTransactionColumns({
@ -207,7 +277,9 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
</label> </label>
<Select <Select
value={filters.type ?? 'all'} value={filters.type ?? 'all'}
onValueChange={(value) => applyFilter('type', value)} onValueChange={(value) =>
applyFilter('type', value)
}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Semua Tipe" /> <SelectValue placeholder="Semua Tipe" />
@ -215,9 +287,15 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
<SelectContent> <SelectContent>
<SelectItem value="all">Semua Tipe</SelectItem> <SelectItem value="all">Semua Tipe</SelectItem>
<SelectItem value="deposit">Deposit</SelectItem> <SelectItem value="deposit">Deposit</SelectItem>
<SelectItem value="withdrawal">Withdrawal</SelectItem> <SelectItem value="withdrawal">
<SelectItem value="expense">Pengeluaran</SelectItem> Withdrawal
<SelectItem value="transfer">Transfer</SelectItem> </SelectItem>
<SelectItem value="expense">
Pengeluaran
</SelectItem>
<SelectItem value="transfer">
Transfer
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -238,11 +316,17 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
</h2> </h2>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setDepositOpen(true)}> <Button
variant="outline"
onClick={() => setDepositOpen(true)}
>
<ArrowDownToLine className="h-4 w-4" /> <ArrowDownToLine className="h-4 w-4" />
Deposit Deposit
</Button> </Button>
<Button variant="outline" onClick={() => setWithdrawalOpen(true)}> <Button
variant="outline"
onClick={() => setWithdrawalOpen(true)}
>
<ArrowUpFromLine className="h-4 w-4" /> <ArrowUpFromLine className="h-4 w-4" />
Withdrawal Withdrawal
</Button> </Button>
@ -279,20 +363,27 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
toolbar={filterToolbar} toolbar={filterToolbar}
/> />
<Dialog open={depositOpen} onOpenChange={(open) => { <Dialog
setDepositOpen(open); open={depositOpen}
onOpenChange={(open) => {
setDepositOpen(open);
if (!open) { if (!open) {
setDepositReceiptKey(null);
setDepositFileMeta(null);
}
}}>
<DialogContent>
<Form action={deposit()} resetOnSuccess onSuccess={() => {
setDepositOpen(false);
setDepositReceiptKey(null); setDepositReceiptKey(null);
setDepositFileMeta(null); setDepositFileMeta(null);
}}> }
}}
>
<DialogContent>
<Form
action={deposit()}
resetOnSuccess
onSuccess={() => {
setDepositOpen(false);
setDepositReceiptKey(null);
setDepositFileMeta(null);
}}
>
{({ errors, processing }) => ( {({ errors, processing }) => (
<> <>
<DialogHeader> <DialogHeader>
@ -301,46 +392,95 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Jumlah <span className="text-destructive">*</span> Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<RupiahInput name="amount" min={1} /> <RupiahInput
<InputError message={errors.amount} /> name="amount"
min={1}
/>
<InputError
message={errors.amount}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Keterangan <span className="text-destructive">*</span> Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
name="description" name="description"
placeholder="Masukkan keterangan" placeholder="Masukkan keterangan"
/> />
<InputError message={errors.description} /> <InputError
message={errors.description}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Bukti{' '} <span className="text-destructive">*</span></Label> <Label>
<input type="hidden" name="receipt_key" value={depositReceiptKey ?? ''} /> Bukti{' '}
<input type="hidden" name="file_size" value={depositFileMeta?.size ?? ''} /> <span className="text-destructive">
<input type="hidden" name="file_mime_type" value={depositFileMeta?.type ?? ''} /> *
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={depositReceiptKey ?? ''}
/>
<input
type="hidden"
name="file_size"
value={
depositFileMeta?.size ?? ''
}
/>
<input
type="hidden"
name="file_mime_type"
value={
depositFileMeta?.type ?? ''
}
/>
<FileUpload <FileUpload
value={depositReceiptKey} value={depositReceiptKey}
onChange={setDepositReceiptKey} onChange={setDepositReceiptKey}
folder="cash-transaction" folder="cash-transaction"
onUploadingChange={setDepositUploading} onUploadingChange={
setDepositUploading
}
onFileMeta={setDepositFileMeta} onFileMeta={setDepositFileMeta}
/> />
<InputError message={errors.receipt_key} /> <InputError
message={errors.receipt_key}
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => setDepositOpen(false)}> <Button
type="button"
variant="outline"
onClick={() =>
setDepositOpen(false)
}
>
Batal Batal
</Button> </Button>
<Button type="submit" disabled={processing || depositUploading}> <Button
type="submit"
disabled={
processing || depositUploading
}
>
{processing {processing
? 'Menyimpan...' ? 'Menyimpan...'
: depositUploading : depositUploading
? 'Mengunggah...' ? 'Mengunggah...'
: 'Simpan'} : 'Simpan'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</> </>
@ -349,20 +489,27 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={withdrawalOpen} onOpenChange={(open) => { <Dialog
setWithdrawalOpen(open); open={withdrawalOpen}
onOpenChange={(open) => {
setWithdrawalOpen(open);
if (!open) { if (!open) {
setWithdrawalReceiptKey(null);
setWithdrawalFileMeta(null);
}
}}>
<DialogContent>
<Form action={withdrawal()} resetOnSuccess onSuccess={() => {
setWithdrawalOpen(false);
setWithdrawalReceiptKey(null); setWithdrawalReceiptKey(null);
setWithdrawalFileMeta(null); setWithdrawalFileMeta(null);
}}> }
}}
>
<DialogContent>
<Form
action={withdrawal()}
resetOnSuccess
onSuccess={() => {
setWithdrawalOpen(false);
setWithdrawalReceiptKey(null);
setWithdrawalFileMeta(null);
}}
>
{({ errors, processing }) => ( {({ errors, processing }) => (
<> <>
<DialogHeader> <DialogHeader>
@ -371,46 +518,104 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Jumlah <span className="text-destructive">*</span> Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<RupiahInput name="amount" min={1} /> <RupiahInput
<InputError message={errors.amount} /> name="amount"
min={1}
/>
<InputError
message={errors.amount}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Keterangan <span className="text-destructive">*</span> Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
name="description" name="description"
placeholder="Masukkan keterangan" placeholder="Masukkan keterangan"
/> />
<InputError message={errors.description} /> <InputError
message={errors.description}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Bukti{' '} <span className="text-destructive">*</span></Label> <Label>
<input type="hidden" name="receipt_key" value={withdrawalReceiptKey ?? ''} /> Bukti{' '}
<input type="hidden" name="file_size" value={withdrawalFileMeta?.size ?? ''} /> <span className="text-destructive">
<input type="hidden" name="file_mime_type" value={withdrawalFileMeta?.type ?? ''} /> *
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={
withdrawalReceiptKey ?? ''
}
/>
<input
type="hidden"
name="file_size"
value={
withdrawalFileMeta?.size ??
''
}
/>
<input
type="hidden"
name="file_mime_type"
value={
withdrawalFileMeta?.type ??
''
}
/>
<FileUpload <FileUpload
value={withdrawalReceiptKey} value={withdrawalReceiptKey}
onChange={setWithdrawalReceiptKey} onChange={
setWithdrawalReceiptKey
}
folder="cash-transaction" folder="cash-transaction"
onUploadingChange={setWithdrawalUploading} onUploadingChange={
onFileMeta={setWithdrawalFileMeta} setWithdrawalUploading
}
onFileMeta={
setWithdrawalFileMeta
}
/>
<InputError
message={errors.receipt_key}
/> />
<InputError message={errors.receipt_key} />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => setWithdrawalOpen(false)}> <Button
type="button"
variant="outline"
onClick={() =>
setWithdrawalOpen(false)
}
>
Batal Batal
</Button> </Button>
<Button type="submit" disabled={processing || withdrawalUploading}> <Button
type="submit"
disabled={
processing ||
withdrawalUploading
}
>
{processing {processing
? 'Menyimpan...' ? 'Menyimpan...'
: withdrawalUploading : withdrawalUploading
? 'Mengunggah...' ? 'Mengunggah...'
: 'Simpan'} : 'Simpan'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</> </>
@ -431,61 +636,121 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
> >
<DialogContent> <DialogContent>
{editing && ( {editing && (
<Form action={updateTransaction(editing.id)} resetOnSuccess onSuccess={() => { <Form
setEditing(null); action={updateTransaction(editing.id)}
setEditReceiptKey(null); resetOnSuccess
setEditFileMeta(null); onSuccess={() => {
}}> setEditing(null);
setEditReceiptKey(null);
setEditFileMeta(null);
}}
>
{({ errors, processing }) => ( {({ errors, processing }) => (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Transaksi</DialogTitle> <DialogTitle>
Edit Transaksi
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Jumlah <span className="text-destructive">*</span> Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<RupiahInput name="amount" defaultValue={editing.amount} min={1} /> <RupiahInput
<InputError message={errors.amount} /> name="amount"
defaultValue={
editing.amount
}
min={1}
/>
<InputError
message={errors.amount}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Keterangan <span className="text-destructive">*</span> Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
name="description" name="description"
placeholder="Masukkan keterangan" placeholder="Masukkan keterangan"
defaultValue={editing.description} defaultValue={
editing.description
}
/>
<InputError
message={errors.description}
/> />
<InputError message={errors.description} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Bukti{' '} <span className="text-destructive">*</span></Label> <Label>
<input type="hidden" name="receipt_key" value={editReceiptKey ?? ''} /> Bukti{' '}
<input type="hidden" name="file_size" value={editFileMeta?.size ?? ''} /> <span className="text-destructive">
<input type="hidden" name="file_mime_type" value={editFileMeta?.type ?? ''} /> *
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={editReceiptKey ?? ''}
/>
<input
type="hidden"
name="file_size"
value={
editFileMeta?.size ?? ''
}
/>
<input
type="hidden"
name="file_mime_type"
value={
editFileMeta?.type ?? ''
}
/>
<FileUpload <FileUpload
value={editReceiptKey} value={editReceiptKey}
onChange={setEditReceiptKey} onChange={setEditReceiptKey}
folder="cash-transaction" folder="cash-transaction"
onUploadingChange={setEditUploading} onUploadingChange={
existingUrl={editing.receipt_url} setEditUploading
}
existingUrl={
editing.receipt_url
}
onFileMeta={setEditFileMeta} onFileMeta={setEditFileMeta}
/> />
<InputError message={errors.receipt_key} /> <InputError
message={errors.receipt_key}
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditing(null)}> <Button
type="button"
variant="outline"
onClick={() => setEditing(null)}
>
Batal Batal
</Button> </Button>
<Button type="submit" disabled={processing || editUploading}> <Button
type="submit"
disabled={
processing || editUploading
}
>
{processing {processing
? 'Menyimpan...' ? 'Menyimpan...'
: editUploading : editUploading
? 'Mengunggah...' ? 'Mengunggah...'
: 'Simpan'} : 'Simpan'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</> </>

View File

@ -33,14 +33,18 @@ export type CashTransaction = {
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString('id-ID', { return (
day: '2-digit', date.toLocaleDateString('id-ID', {
month: 'short', day: '2-digit',
year: 'numeric', month: 'short',
}) + ' ' + date.toLocaleTimeString('id-ID', { year: 'numeric',
hour: '2-digit', }) +
minute: '2-digit', ' ' +
}); date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
})
);
} }
function getTypeLabel(type: string): string { function getTypeLabel(type: string): string {
@ -105,9 +109,7 @@ export function createTransactionColumns(
id: 'no', id: 'no',
header: () => <span className="block text-center">No</span>, header: () => <span className="block text-center">No</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block text-center"> <span className="block text-center">{row.index + 1}</span>
{row.index + 1}
</span>
), ),
meta: { meta: {
className: 'w-[50px] text-center', className: 'w-[50px] text-center',
@ -121,9 +123,7 @@ export function createTransactionColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Tanggal</span> <span>Tanggal</span>
@ -142,8 +142,14 @@ export function createTransactionColumns(
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{getTypeLabel(transaction.type)}</span> <span className="font-medium">
<span className="text-xs text-muted-foreground">{getReferenceLabel(transaction.reference?.type ?? '')}</span> {getTypeLabel(transaction.type)}
</span>
<span className="text-xs text-muted-foreground">
{getReferenceLabel(
transaction.reference?.type ?? '',
)}
</span>
</div> </div>
); );
}, },
@ -155,9 +161,7 @@ export function createTransactionColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Jumlah</span> <span>Jumlah</span>
@ -169,8 +173,15 @@ export function createTransactionColumns(
const isDeposit = transaction.type === 'deposit'; const isDeposit = transaction.type === 'deposit';
return ( return (
<span className={isDeposit ? 'text-green-600 font-medium' : 'text-red-600 font-medium'}> <span
{isDeposit ? '+' : '-'} {formatCurrency(row.getValue('amount') as number)} className={
isDeposit
? 'font-medium text-green-600'
: 'font-medium text-red-600'
}
>
{isDeposit ? '+' : '-'}{' '}
{formatCurrency(row.getValue('amount') as number)}
</span> </span>
); );
}, },
@ -182,9 +193,7 @@ export function createTransactionColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Saldo Setelah</span> <span>Saldo Setelah</span>
@ -192,14 +201,18 @@ export function createTransactionColumns(
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-medium">{formatCurrency(row.getValue('balance_after') as number)}</span> <span className="font-medium">
{formatCurrency(row.getValue('balance_after') as number)}
</span>
), ),
}, },
{ {
accessorKey: 'description', accessorKey: 'description',
header: () => <span>Keterangan</span>, header: () => <span>Keterangan</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="max-w-[200px] truncate block">{row.getValue('description') as string}</span> <span className="block max-w-[200px] truncate">
{row.getValue('description') as string}
</span>
), ),
}, },
{ {
@ -212,7 +225,12 @@ export function createTransactionColumns(
return <span className="text-muted-foreground">-</span>; return <span className="text-muted-foreground">-</span>;
} }
return <ReceiptPreview url={receiptUrl} title={row.original.description} />; return (
<ReceiptPreview
url={receiptUrl}
title={row.original.description}
/>
);
}, },
}, },
{ {
@ -233,7 +251,9 @@ export function createTransactionColumns(
}, },
cell: ({ row }) => { cell: ({ row }) => {
const transaction = row.original; const transaction = row.original;
const canEdit = transaction.type === 'deposit' || transaction.type === 'withdrawal'; const canEdit =
transaction.type === 'deposit' ||
transaction.type === 'withdrawal';
if (!canEdit) { if (!canEdit) {
return <span className="block text-center">-</span>; return <span className="block text-center">-</span>;
@ -252,9 +272,7 @@ export function createTransactionColumns(
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">Edit</TooltipContent>
Edit
</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
@ -262,7 +280,9 @@ export function createTransactionColumns(
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleDeleteClick(transaction)} onClick={() =>
handleDeleteClick(transaction)
}
> >
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
</Button> </Button>

View File

@ -1,5 +1,11 @@
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { ArrowUpDown, CheckCircle, CircleDollarSign, Pencil, Trash2 } from 'lucide-react'; import {
ArrowUpDown,
CheckCircle,
CircleDollarSign,
Pencil,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
@ -30,14 +36,18 @@ export type EmployeeAdvance = {
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString('id-ID', { return (
day: '2-digit', date.toLocaleDateString('id-ID', {
month: 'short', day: '2-digit',
year: 'numeric', month: 'short',
}) + ' ' + date.toLocaleTimeString('id-ID', { year: 'numeric',
hour: '2-digit', }) +
minute: '2-digit', ' ' +
}); date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
})
);
} }
function formatShortDate(dateString: string): string { function formatShortDate(dateString: string): string {
@ -100,9 +110,7 @@ export function createEmployeeAdvanceColumns(
id: 'no', id: 'no',
header: () => <span className="block text-center">No</span>, header: () => <span className="block text-center">No</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block text-center"> <span className="block text-center">{row.index + 1}</span>
{row.index + 1}
</span>
), ),
meta: { meta: {
className: 'w-[50px] text-center', className: 'w-[50px] text-center',
@ -116,9 +124,7 @@ export function createEmployeeAdvanceColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Tanggal</span> <span>Tanggal</span>
@ -135,7 +141,11 @@ export function createEmployeeAdvanceColumns(
cell: ({ row }) => { cell: ({ row }) => {
const employee = row.original.employee; const employee = row.original.employee;
return <span>{employee?.user?.user_profile?.full_name ?? '-'}</span>; return (
<span>
{employee?.user?.user_profile?.full_name ?? '-'}
</span>
);
}, },
}, },
{ {
@ -145,9 +155,7 @@ export function createEmployeeAdvanceColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Jumlah</span> <span>Jumlah</span>
@ -155,7 +163,7 @@ export function createEmployeeAdvanceColumns(
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-red-600 font-medium"> <span className="font-medium text-red-600">
- {formatCurrency(row.getValue('amount') as number)} - {formatCurrency(row.getValue('amount') as number)}
</span> </span>
), ),
@ -164,7 +172,9 @@ export function createEmployeeAdvanceColumns(
accessorKey: 'description', accessorKey: 'description',
header: () => <span>Keterangan</span>, header: () => <span>Keterangan</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="max-w-[200px] truncate block">{row.getValue('description') as string}</span> <span className="block max-w-[200px] truncate">
{row.getValue('description') as string}
</span>
), ),
}, },
{ {
@ -174,9 +184,7 @@ export function createEmployeeAdvanceColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Jatuh Tempo</span> <span>Jatuh Tempo</span>
@ -184,7 +192,9 @@ export function createEmployeeAdvanceColumns(
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span>{formatShortDate(row.getValue('due_date') as string)}</span> <span>
{formatShortDate(row.getValue('due_date') as string)}
</span>
), ),
}, },
{ {
@ -257,9 +267,7 @@ export function createEmployeeAdvanceColumns(
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">Edit</TooltipContent>
Edit
</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>

View File

@ -17,7 +17,14 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { destroy, index as employeeAdvanceIndex, store, update, approve, pay } from '@/routes/admin/finance/employee-advances'; import {
destroy,
index as employeeAdvanceIndex,
store,
update,
approve,
pay,
} from '@/routes/admin/finance/employee-advances';
import { createEmployeeAdvanceColumns } from './columns'; import { createEmployeeAdvanceColumns } from './columns';
import type { EmployeeAdvance } from './columns'; import type { EmployeeAdvance } from './columns';
@ -38,9 +45,14 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
const [approving, setApproving] = useState<EmployeeAdvance | null>(null); const [approving, setApproving] = useState<EmployeeAdvance | null>(null);
const [paying, setPaying] = useState<EmployeeAdvance | null>(null); const [paying, setPaying] = useState<EmployeeAdvance | null>(null);
const [dueDate, setDueDate] = useState<Date | undefined>(undefined); const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
const [editingDueDate, setEditingDueDate] = useState<Date | undefined>(undefined); const [editingDueDate, setEditingDueDate] = useState<Date | undefined>(
undefined,
);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' }); const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = { const pagination: PaginationState = {
current_page: employeeAdvances.current_page, current_page: employeeAdvances.current_page,
@ -72,9 +84,13 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
return; return;
} }
router.post(approve(approving.id), {}, { router.post(
onSuccess: () => setApproving(null), approve(approving.id),
}); {},
{
onSuccess: () => setApproving(null),
},
);
} }
function handlePay() { function handlePay() {
@ -82,51 +98,74 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
return; return;
} }
router.post(pay(paying.id), {}, { router.post(
onSuccess: () => setPaying(null), pay(paying.id),
}); {},
{
onSuccess: () => setPaying(null),
},
);
} }
function handlePageChange(page: number) { function handlePageChange(page: number) {
router.get(employeeAdvanceIndex.url(), { router.get(
page, employeeAdvanceIndex.url(),
per_page: pagination.per_page, {
search, page,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
function handlePerPageChange(perPage: number) { function handlePerPageChange(perPage: number) {
router.get(employeeAdvanceIndex.url(), { router.get(
page: 1, employeeAdvanceIndex.url(),
per_page: perPage, {
search, page: 1,
sort: sort.column, per_page: perPage,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
const handleSearchChange = useCallback((value: string) => { const handleSearchChange = useCallback(
setSearch(value); (value: string) => {
router.get(employeeAdvanceIndex.url(), { setSearch(value);
page: 1, router.get(
per_page: pagination.per_page, employeeAdvanceIndex.url(),
search: value, {
sort: sort.column, page: 1,
direction: sort.direction, per_page: pagination.per_page,
}, { preserveState: true, replace: true }); search: value,
}, [pagination.per_page, sort]); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') { function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction }); setSort({ column, direction });
router.get(employeeAdvanceIndex.url(), { router.get(
page: 1, employeeAdvanceIndex.url(),
per_page: pagination.per_page, {
search, page: 1,
sort: column, per_page: pagination.per_page,
direction, search,
}, { preserveState: true, replace: true }); sort: column,
direction,
},
{ preserveState: true, replace: true },
);
} }
const columns = createEmployeeAdvanceColumns({ const columns = createEmployeeAdvanceColumns({
@ -147,13 +186,16 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
Kasbon Kasbon
</h2> </h2>
</div> </div>
<Dialog open={createOpen} onOpenChange={(open) => { <Dialog
setCreateOpen(open); open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) { if (!open) {
setDueDate(undefined); setDueDate(undefined);
} }
}}> }}
>
<Button asChild> <Button asChild>
<button <button
type="button" type="button"
@ -164,57 +206,98 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
</button> </button>
</Button> </Button>
<DialogContent> <DialogContent>
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}> <Form
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors, processing }) => { {({ errors, processing }) => {
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Tambah Kasbon</DialogTitle> <DialogTitle>
Tambah Kasbon
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Jumlah{' '} <span className="text-destructive">*</span> Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<RupiahInput name="amount" min={1} /> <RupiahInput
<InputError message={errors.amount} /> name="amount"
min={1}
/>
<InputError
message={errors.amount}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="description"> <Label htmlFor="description">
Keterangan{' '} <span className="text-destructive">*</span> Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="description" id="description"
name="description" name="description"
placeholder="Masukkan keterangan" placeholder="Masukkan keterangan"
/> />
<InputError message={errors.description} /> <InputError
message={
errors.description
}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="due_date"> <Label htmlFor="due_date">
Jatuh Tempo{' '} <span className="text-destructive">*</span> Jatuh Tempo{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<input type="hidden" name="due_date" value={dueDate ? dueDate.toISOString().split('T')[0] : ''} /> <input
type="hidden"
name="due_date"
value={
dueDate
? dueDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker <DatePicker
value={dueDate} value={dueDate}
onChange={setDueDate} onChange={setDueDate}
placeholder="Pilih jatuh tempo" placeholder="Pilih jatuh tempo"
min={new Date()} min={new Date()}
/> />
<InputError message={errors.due_date} /> <InputError
message={
errors.due_date
}
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => setCreateOpen(false)} onClick={() =>
setCreateOpen(false)
}
> >
Batal Batal
</Button> </Button>
<Button <Button
type='submit' type="submit"
disabled={processing} disabled={processing}
> >
{processing {processing
@ -256,43 +339,95 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
> >
<DialogContent> <DialogContent>
{editing && ( {editing && (
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => { <Form
setEditing(null); action={update(editing.id)}
setEditingDueDate(undefined); resetOnSuccess
}}> onSuccess={() => {
setEditing(null);
setEditingDueDate(undefined);
}}
>
{({ errors, processing }) => { {({ errors, processing }) => {
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Kasbon</DialogTitle> <DialogTitle>
Edit Kasbon
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Jumlah{' '} <span className="text-destructive">*</span></Label> <Label>
<RupiahInput name="amount" defaultValue={editing.amount} min={1} /> Jumlah{' '}
<InputError message={errors.amount} /> <span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
defaultValue={
editing.amount
}
min={1}
/>
<InputError
message={errors.amount}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="edit-description">Keterangan{' '} <span className="text-destructive">*</span></Label> <Label htmlFor="edit-description">
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input <Input
id="edit-description" id="edit-description"
name="description" name="description"
placeholder="Masukkan keterangan" placeholder="Masukkan keterangan"
defaultValue={editing.description} defaultValue={
editing.description
}
/>
<InputError
message={
errors.description
}
/> />
<InputError message={errors.description} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="edit-due_date">Jatuh Tempo{' '} <span className="text-destructive">*</span></Label> <Label htmlFor="edit-due_date">
<input type="hidden" name="due_date" value={editingDueDate ? editingDueDate.toISOString().split('T')[0] : ''} /> Jatuh Tempo{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="due_date"
value={
editingDueDate
? editingDueDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker <DatePicker
value={editingDueDate} value={editingDueDate}
onChange={setEditingDueDate} onChange={
setEditingDueDate
}
placeholder="Pilih jatuh tempo" placeholder="Pilih jatuh tempo"
min={new Date()} min={new Date()}
/> />
<InputError message={errors.due_date} /> <InputError
message={
errors.due_date
}
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>

View File

@ -28,14 +28,18 @@ export type Expense = {
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString('id-ID', { return (
day: '2-digit', date.toLocaleDateString('id-ID', {
month: 'short', day: '2-digit',
year: 'numeric', month: 'short',
}) + ' ' + date.toLocaleTimeString('id-ID', { year: 'numeric',
hour: '2-digit', }) +
minute: '2-digit', ' ' +
}); date.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
})
);
} }
type CreateColumnsParams = { type CreateColumnsParams = {
@ -78,9 +82,7 @@ export function createExpenseColumns(
id: 'no', id: 'no',
header: () => <span className="block text-center">No</span>, header: () => <span className="block text-center">No</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block text-center"> <span className="block text-center">{row.index + 1}</span>
{row.index + 1}
</span>
), ),
meta: { meta: {
className: 'w-[50px] text-center', className: 'w-[50px] text-center',
@ -94,9 +96,7 @@ export function createExpenseColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Tanggal</span> <span>Tanggal</span>
@ -111,7 +111,9 @@ export function createExpenseColumns(
accessorKey: 'description', accessorKey: 'description',
header: () => <span>Keterangan</span>, header: () => <span>Keterangan</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="max-w-[200px] truncate block">{row.getValue('description') as string}</span> <span className="block max-w-[200px] truncate">
{row.getValue('description') as string}
</span>
), ),
}, },
{ {
@ -124,7 +126,12 @@ export function createExpenseColumns(
return <span className="text-muted-foreground">-</span>; return <span className="text-muted-foreground">-</span>;
} }
return <ReceiptPreview url={receiptUrl} title={row.original.description} />; return (
<ReceiptPreview
url={receiptUrl}
title={row.original.description}
/>
);
}, },
}, },
{ {
@ -134,9 +141,7 @@ export function createExpenseColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Jumlah</span> <span>Jumlah</span>
@ -144,7 +149,7 @@ export function createExpenseColumns(
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-red-600 font-medium"> <span className="font-medium text-red-600">
- {formatCurrency(row.getValue('amount') as number)} - {formatCurrency(row.getValue('amount') as number)}
</span> </span>
), ),
@ -176,16 +181,12 @@ export function createExpenseColumns(
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => onClick={() => handleEdit(expense)}
handleEdit(expense)
}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">Edit</TooltipContent>
Edit
</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>

View File

@ -17,7 +17,12 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { destroy, index as expenseIndex, store, update } from '@/routes/admin/finance/expenses'; import {
destroy,
index as expenseIndex,
store,
update,
} from '@/routes/admin/finance/expenses';
import { createExpenseColumns } from './columns'; import { createExpenseColumns } from './columns';
import type { Expense } from './columns'; import type { Expense } from './columns';
@ -35,14 +40,25 @@ export default function ExpenseIndex({ expenses }: Props) {
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Expense | null>(null); const [editing, setEditing] = useState<Expense | null>(null);
const [deleting, setDeleting] = useState<Expense | null>(null); const [deleting, setDeleting] = useState<Expense | null>(null);
const [createReceiptKey, setCreateReceiptKey] = useState<string | null>(null); const [createReceiptKey, setCreateReceiptKey] = useState<string | null>(
null,
);
const [editReceiptKey, setEditReceiptKey] = useState<string | null>(null); const [editReceiptKey, setEditReceiptKey] = useState<string | null>(null);
const [createUploading, setCreateUploading] = useState(false); const [createUploading, setCreateUploading] = useState(false);
const [editUploading, setEditUploading] = useState(false); const [editUploading, setEditUploading] = useState(false);
const [createFileMeta, setCreateFileMeta] = useState<{ size: number; type: string } | null>(null); const [createFileMeta, setCreateFileMeta] = useState<{
const [editFileMeta, setEditFileMeta] = useState<{ size: number; type: string } | null>(null); size: number;
type: string;
} | null>(null);
const [editFileMeta, setEditFileMeta] = useState<{
size: number;
type: string;
} | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' }); const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = { const pagination: PaginationState = {
current_page: expenses.current_page, current_page: expenses.current_page,
@ -52,45 +68,64 @@ export default function ExpenseIndex({ expenses }: Props) {
}; };
function handlePageChange(page: number) { function handlePageChange(page: number) {
router.get(expenseIndex.url(), { router.get(
page, expenseIndex.url(),
per_page: pagination.per_page, {
search, page,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
function handlePerPageChange(perPage: number) { function handlePerPageChange(perPage: number) {
router.get(expenseIndex.url(), { router.get(
page: 1, expenseIndex.url(),
per_page: perPage, {
search, page: 1,
sort: sort.column, per_page: perPage,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
const handleSearchChange = useCallback((value: string) => { const handleSearchChange = useCallback(
setSearch(value); (value: string) => {
router.get(expenseIndex.url(), { setSearch(value);
page: 1, router.get(
per_page: pagination.per_page, expenseIndex.url(),
search: value, {
sort: sort.column, page: 1,
direction: sort.direction, per_page: pagination.per_page,
}, { preserveState: true, replace: true }); search: value,
}, [pagination.per_page, sort]); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') { function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction }); setSort({ column, direction });
router.get(expenseIndex.url(), { router.get(
page: 1, expenseIndex.url(),
per_page: pagination.per_page, {
search, page: 1,
sort: column, per_page: pagination.per_page,
direction, search,
}, { preserveState: true, replace: true }); sort: column,
direction,
},
{ preserveState: true, replace: true },
);
} }
function handleDelete() { function handleDelete() {
@ -122,14 +157,17 @@ export default function ExpenseIndex({ expenses }: Props) {
Pengeluaran Pengeluaran
</h2> </h2>
</div> </div>
<Dialog open={createOpen} onOpenChange={(open) => { <Dialog
setCreateOpen(open); open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) { if (!open) {
setCreateReceiptKey(null); setCreateReceiptKey(null);
setCreateFileMeta(null); setCreateFileMeta(null);
} }
}}> }}
>
<Button asChild> <Button asChild>
<button <button
type="button" type="button"
@ -140,69 +178,130 @@ export default function ExpenseIndex({ expenses }: Props) {
</button> </button>
</Button> </Button>
<DialogContent> <DialogContent>
<Form action={store()} resetOnSuccess onSuccess={() => { <Form
setCreateOpen(false); action={store()}
setCreateReceiptKey(null); resetOnSuccess
setCreateFileMeta(null); onSuccess={() => {
}}> setCreateOpen(false);
setCreateReceiptKey(null);
setCreateFileMeta(null);
}}
>
{({ errors, processing }) => { {({ errors, processing }) => {
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Tambah Pengeluaran</DialogTitle> <DialogTitle>
Tambah Pengeluaran
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Jumlah{' '} <span className="text-destructive">*</span> Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<RupiahInput name="amount" min={1} /> <RupiahInput
<InputError message={errors.amount} /> name="amount"
min={1}
/>
<InputError
message={errors.amount}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="description"> <Label htmlFor="description">
Keterangan{' '} <span className="text-destructive">*</span> Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="description" id="description"
name="description" name="description"
placeholder="Masukkan keterangan" placeholder="Masukkan keterangan"
/> />
<InputError message={errors.description} /> <InputError
message={
errors.description
}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Bukti{' '} <span className="text-destructive">*</span></Label> <Label>
<input type="hidden" name="receipt_key" value={createReceiptKey ?? ''} /> Bukti{' '}
<input type="hidden" name="file_size" value={createFileMeta?.size ?? ''} /> <span className="text-destructive">
<input type="hidden" name="file_mime_type" value={createFileMeta?.type ?? ''} /> *
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={
createReceiptKey ??
''
}
/>
<input
type="hidden"
name="file_size"
value={
createFileMeta?.size ??
''
}
/>
<input
type="hidden"
name="file_mime_type"
value={
createFileMeta?.type ??
''
}
/>
<FileUpload <FileUpload
value={createReceiptKey} value={createReceiptKey}
onChange={setCreateReceiptKey} onChange={
setCreateReceiptKey
}
folder="expense" folder="expense"
onUploadingChange={setCreateUploading} onUploadingChange={
onFileMeta={setCreateFileMeta} setCreateUploading
}
onFileMeta={
setCreateFileMeta
}
/>
<InputError
message={
errors.receipt_key
}
/> />
<InputError message={errors.receipt_key} />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => setCreateOpen(false)} onClick={() =>
setCreateOpen(false)
}
> >
Batal Batal
</Button> </Button>
<Button <Button
type='submit' type="submit"
disabled={processing || createUploading} disabled={
processing ||
createUploading
}
> >
{processing {processing
? 'Menyimpan...' ? 'Menyimpan...'
: createUploading : createUploading
? 'Mengunggah...' ? 'Mengunggah...'
: 'Simpan'} : 'Simpan'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</> </>
@ -240,48 +339,114 @@ export default function ExpenseIndex({ expenses }: Props) {
> >
<DialogContent> <DialogContent>
{editing && ( {editing && (
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => { <Form
setEditing(null); action={update(editing.id)}
setEditReceiptKey(null); resetOnSuccess
setEditFileMeta(null); onSuccess={() => {
}}> setEditing(null);
setEditReceiptKey(null);
setEditFileMeta(null);
}}
>
{({ errors, processing }) => { {({ errors, processing }) => {
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Pengeluaran</DialogTitle> <DialogTitle>
Edit Pengeluaran
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Jumlah{' '} <span className="text-destructive">*</span></Label> <Label>
<RupiahInput name="amount" defaultValue={editing.amount} min={1} /> Jumlah{' '}
<InputError message={errors.amount} /> <span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
defaultValue={
editing.amount
}
min={1}
/>
<InputError
message={errors.amount}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="edit-description">Keterangan{' '} <span className="text-destructive">*</span></Label> <Label htmlFor="edit-description">
Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input <Input
id="edit-description" id="edit-description"
name="description" name="description"
placeholder="Masukkan keterangan" placeholder="Masukkan keterangan"
defaultValue={editing.description} defaultValue={
editing.description
}
/>
<InputError
message={
errors.description
}
/> />
<InputError message={errors.description} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Bukti{' '} <span className="text-destructive">*</span></Label> <Label>
<input type="hidden" name="receipt_key" value={editReceiptKey ?? ''} /> Bukti{' '}
<input type="hidden" name="file_size" value={editFileMeta?.size ?? ''} /> <span className="text-destructive">
<input type="hidden" name="file_mime_type" value={editFileMeta?.type ?? ''} /> *
</span>
</Label>
<input
type="hidden"
name="receipt_key"
value={
editReceiptKey ?? ''
}
/>
<input
type="hidden"
name="file_size"
value={
editFileMeta?.size ??
''
}
/>
<input
type="hidden"
name="file_mime_type"
value={
editFileMeta?.type ??
''
}
/>
<FileUpload <FileUpload
value={editReceiptKey} value={editReceiptKey}
onChange={setEditReceiptKey} onChange={
setEditReceiptKey
}
folder="expense" folder="expense"
onUploadingChange={setEditUploading} onUploadingChange={
existingUrl={editing.receipt_url} setEditUploading
onFileMeta={setEditFileMeta} }
existingUrl={
editing.receipt_url
}
onFileMeta={
setEditFileMeta
}
/>
<InputError
message={
errors.receipt_key
}
/> />
<InputError message={errors.receipt_key} />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
@ -296,13 +461,16 @@ export default function ExpenseIndex({ expenses }: Props) {
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={processing || editUploading} disabled={
processing ||
editUploading
}
> >
{processing {processing
? 'Menyimpan...' ? 'Menyimpan...'
: editUploading : editUploading
? 'Mengunggah...' ? 'Mengunggah...'
: 'Simpan'} : 'Simpan'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</> </>

View File

@ -27,8 +27,19 @@ export type PayrollPeriod = {
}; };
const MONTH_NAMES = [ const MONTH_NAMES = [
'', 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', '',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember', 'Januari',
'Februari',
'Maret',
'April',
'Mei',
'Juni',
'Juli',
'Agustus',
'September',
'Oktober',
'November',
'Desember',
]; ];
function formatPeriod(period: PayrollPeriod): string { function formatPeriod(period: PayrollPeriod): string {
@ -72,9 +83,7 @@ export function createPayrollPeriodColumns(
id: 'no', id: 'no',
header: () => <span className="block text-center">No</span>, header: () => <span className="block text-center">No</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block text-center"> <span className="block text-center">{row.index + 1}</span>
{row.index + 1}
</span>
), ),
meta: { meta: {
className: 'w-[50px] text-center', className: 'w-[50px] text-center',
@ -88,9 +97,7 @@ export function createPayrollPeriodColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Periode</span> <span>Periode</span>
@ -98,14 +105,18 @@ export function createPayrollPeriodColumns(
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-medium">{formatPeriod(row.original)}</span> <span className="font-medium">
{formatPeriod(row.original)}
</span>
), ),
}, },
{ {
accessorKey: 'payrolls_count', accessorKey: 'payrolls_count',
header: () => <span>Jumlah Karyawan</span>, header: () => <span>Jumlah Karyawan</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-center">{row.getValue('payrolls_count') as number}</span> <span className="text-center">
{row.getValue('payrolls_count') as number}
</span>
), ),
}, },
{ {
@ -115,9 +126,7 @@ export function createPayrollPeriodColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Total Gaji</span> <span>Total Gaji</span>
@ -126,7 +135,10 @@ export function createPayrollPeriodColumns(
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-medium"> <span className="font-medium">
{formatCurrency((row.getValue('payrolls_sum_total_amount') as number) ?? 0)} {formatCurrency(
(row.getValue('payrolls_sum_total_amount') as number) ??
0,
)}
</span> </span>
), ),
}, },
@ -137,9 +149,7 @@ export function createPayrollPeriodColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Bonus</span> <span>Bonus</span>
@ -147,11 +157,19 @@ export function createPayrollPeriodColumns(
</Button> </Button>
), ),
cell: ({ row }) => { cell: ({ row }) => {
const value = (row.getValue('payrolls_sum_bonus_amount') as number) ?? 0; const value =
(row.getValue('payrolls_sum_bonus_amount') as number) ?? 0;
return ( return (
<span className={value > 0 ? 'text-green-600 font-medium' : 'text-muted-foreground'}> <span
{value > 0 ? '+ ' : ''}{formatCurrency(value)} className={
value > 0
? 'font-medium text-green-600'
: 'text-muted-foreground'
}
>
{value > 0 ? '+ ' : ''}
{formatCurrency(value)}
</span> </span>
); );
}, },
@ -163,9 +181,7 @@ export function createPayrollPeriodColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Potongan</span> <span>Potongan</span>
@ -173,11 +189,20 @@ export function createPayrollPeriodColumns(
</Button> </Button>
), ),
cell: ({ row }) => { cell: ({ row }) => {
const value = (row.getValue('payrolls_sum_deduction_amount') as number) ?? 0; const value =
(row.getValue('payrolls_sum_deduction_amount') as number) ??
0;
return ( return (
<span className={value > 0 ? 'text-red-600 font-medium' : 'text-muted-foreground'}> <span
{value > 0 ? '- ' : ''}{formatCurrency(value)} className={
value > 0
? 'font-medium text-red-600'
: 'text-muted-foreground'
}
>
{value > 0 ? '- ' : ''}
{formatCurrency(value)}
</span> </span>
); );
}, },
@ -195,13 +220,19 @@ export function createPayrollPeriodColumns(
return ( return (
<div className="flex flex-col gap-0.5 text-xs"> <div className="flex flex-col gap-0.5 text-xs">
{paid > 0 && ( {paid > 0 && (
<span className="text-green-600">{paid} dibayar</span> <span className="text-green-600">
{paid} dibayar
</span>
)} )}
{unpaid > 0 && ( {unpaid > 0 && (
<span className="text-yellow-600">{unpaid} menunggu</span> <span className="text-yellow-600">
{unpaid} menunggu
</span>
)} )}
{cancelled > 0 && ( {cancelled > 0 && (
<span className="text-red-600">{cancelled} dibatalkan</span> <span className="text-red-600">
{cancelled} dibatalkan
</span>
)} )}
</div> </div>
); );
@ -229,11 +260,7 @@ export function createPayrollPeriodColumns(
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button variant="ghost" size="icon" asChild>
variant="ghost"
size="icon"
asChild
>
<Link href={showUrl(period.id)}> <Link href={showUrl(period.id)}>
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Link> </Link>

View File

@ -28,15 +28,29 @@ type Props = {
}; };
const MONTH_NAMES = [ const MONTH_NAMES = [
'', 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', '',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember', 'Januari',
'Februari',
'Maret',
'April',
'Mei',
'Juni',
'Juli',
'Agustus',
'September',
'Oktober',
'November',
'Desember',
]; ];
export default function PayrollPeriodIndex({ payrollPeriods }: Props) { export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
const [closing, setClosing] = useState<PayrollPeriod | null>(null); const [closing, setClosing] = useState<PayrollPeriod | null>(null);
const [reopening, setReopening] = useState<PayrollPeriod | null>(null); const [reopening, setReopening] = useState<PayrollPeriod | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' }); const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = { const pagination: PaginationState = {
current_page: payrollPeriods.current_page, current_page: payrollPeriods.current_page,
@ -47,64 +61,91 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
function handleClose() { function handleClose() {
if (!closing) { if (!closing) {
return; return;
} }
router.post(close(closing.id), {}, { router.post(
onSuccess: () => setClosing(null), close(closing.id),
}); {},
{
onSuccess: () => setClosing(null),
},
);
} }
function handleReopen() { function handleReopen() {
if (!reopening) { if (!reopening) {
return; return;
} }
router.post(reopen(reopening.id), {}, { router.post(
onSuccess: () => setReopening(null), reopen(reopening.id),
}); {},
{
onSuccess: () => setReopening(null),
},
);
} }
function handlePageChange(page: number) { function handlePageChange(page: number) {
router.get(payrollPeriodsIndex(), { router.get(
page, payrollPeriodsIndex(),
per_page: pagination.per_page, {
search, page,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
function handlePerPageChange(perPage: number) { function handlePerPageChange(perPage: number) {
router.get(payrollPeriodsIndex(), { router.get(
page: 1, payrollPeriodsIndex(),
per_page: perPage, {
search, page: 1,
sort: sort.column, per_page: perPage,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
const handleSearchChange = useCallback((value: string) => { const handleSearchChange = useCallback(
setSearch(value); (value: string) => {
router.get(payrollPeriodsIndex(), { setSearch(value);
page: 1, router.get(
per_page: pagination.per_page, payrollPeriodsIndex(),
search: value, {
sort: sort.column, page: 1,
direction: sort.direction, per_page: pagination.per_page,
}, { preserveState: true, replace: true }); search: value,
}, [pagination.per_page, sort]); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') { function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction }); setSort({ column, direction });
router.get(payrollPeriodsIndex(), { router.get(
page: 1, payrollPeriodsIndex(),
per_page: pagination.per_page, {
search, page: 1,
sort: column, per_page: pagination.per_page,
direction, search,
}, { preserveState: true, replace: true }); sort: column,
direction,
},
{ preserveState: true, replace: true },
);
} }
const columns = createPayrollPeriodColumns({ const columns = createPayrollPeriodColumns({
@ -145,8 +186,8 @@ return;
open={closing !== null} open={closing !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setClosing(null); setClosing(null);
} }
}} }}
title="Tutup Periode Gaji" title="Tutup Periode Gaji"
description={`Apakah Anda yakin ingin menutup periode gaji ${closing ? `${MONTH_NAMES[closing.month]} ${closing.year}` : ''}? Semua gaji harus sudah dibayar sebelum periode ditutup.`} description={`Apakah Anda yakin ingin menutup periode gaji ${closing ? `${MONTH_NAMES[closing.month]} ${closing.year}` : ''}? Semua gaji harus sudah dibayar sebelum periode ditutup.`}
@ -158,8 +199,8 @@ setClosing(null);
open={reopening !== null} open={reopening !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setReopening(null); setReopening(null);
} }
}} }}
title="Buka Periode Gaji" title="Buka Periode Gaji"
description={`Apakah Anda yakin ingin membuka kembali periode gaji ${reopening ? `${MONTH_NAMES[reopening.month]} ${reopening.year}` : ''}?`} description={`Apakah Anda yakin ingin membuka kembali periode gaji ${reopening ? `${MONTH_NAMES[reopening.month]} ${reopening.year}` : ''}?`}

View File

@ -1,5 +1,11 @@
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { ArrowUpDown, CircleDollarSign, Pencil, Trash2, XCircle } from 'lucide-react'; import {
ArrowUpDown,
CircleDollarSign,
Pencil,
Trash2,
XCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
@ -65,22 +71,28 @@ type CreateColumnsParams = {
handlePay: (payroll: Payroll) => void; handlePay: (payroll: Payroll) => void;
handleCancel: (payroll: Payroll) => void; handleCancel: (payroll: Payroll) => void;
handleAddAdjustment: (payroll: Payroll) => void; handleAddAdjustment: (payroll: Payroll) => void;
handleDeleteAdjustment: (adjustment: PayrollAdjustment, payrollId: number) => void; handleDeleteAdjustment: (
adjustment: PayrollAdjustment,
payrollId: number,
) => void;
}; };
export function createPayrollColumns( export function createPayrollColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<Payroll>[] { ): ColumnDef<Payroll>[] {
const { handlePay, handleCancel, handleAddAdjustment, handleDeleteAdjustment } = params; const {
handlePay,
handleCancel,
handleAddAdjustment,
handleDeleteAdjustment,
} = params;
return [ return [
{ {
id: 'no', id: 'no',
header: () => <span className="block text-center">No</span>, header: () => <span className="block text-center">No</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block text-center"> <span className="block text-center">{row.index + 1}</span>
{row.index + 1}
</span>
), ),
meta: { meta: {
className: 'w-[50px] text-center', className: 'w-[50px] text-center',
@ -93,7 +105,11 @@ export function createPayrollColumns(
cell: ({ row }) => { cell: ({ row }) => {
const employee = row.original.employee; const employee = row.original.employee;
return <span className="font-medium">{employee?.user?.user_profile?.full_name ?? '-'}</span>; return (
<span className="font-medium">
{employee?.user?.user_profile?.full_name ?? '-'}
</span>
);
}, },
}, },
{ {
@ -103,9 +119,7 @@ export function createPayrollColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Gaji Pokok</span> <span>Gaji Pokok</span>
@ -113,7 +127,9 @@ export function createPayrollColumns(
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span>{formatCurrency(row.getValue('base_salary') as number)}</span> <span>
{formatCurrency(row.getValue('base_salary') as number)}
</span>
), ),
}, },
{ {
@ -123,9 +139,7 @@ export function createPayrollColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Bonus</span> <span>Bonus</span>
@ -136,8 +150,13 @@ export function createPayrollColumns(
const value = row.getValue('bonus_amount') as number; const value = row.getValue('bonus_amount') as number;
return ( return (
<span className={value > 0 ? 'text-green-600 font-medium' : ''}> <span
{value > 0 ? '+ ' : ''}{formatCurrency(value)} className={
value > 0 ? 'font-medium text-green-600' : ''
}
>
{value > 0 ? '+ ' : ''}
{formatCurrency(value)}
</span> </span>
); );
}, },
@ -149,9 +168,7 @@ export function createPayrollColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Potongan</span> <span>Potongan</span>
@ -162,8 +179,11 @@ export function createPayrollColumns(
const value = row.getValue('deduction_amount') as number; const value = row.getValue('deduction_amount') as number;
return ( return (
<span className={value > 0 ? 'text-red-600 font-medium' : ''}> <span
{value > 0 ? '- ' : ''}{formatCurrency(value)} className={value > 0 ? 'font-medium text-red-600' : ''}
>
{value > 0 ? '- ' : ''}
{formatCurrency(value)}
</span> </span>
); );
}, },
@ -175,9 +195,7 @@ export function createPayrollColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Total</span> <span>Total</span>
@ -185,7 +203,9 @@ export function createPayrollColumns(
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-semibold">{formatCurrency(row.getValue('total_amount') as number)}</span> <span className="font-semibold">
{formatCurrency(row.getValue('total_amount') as number)}
</span>
), ),
}, },
{ {
@ -209,16 +229,31 @@ export function createPayrollColumns(
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{adjustments.map((adj: PayrollAdjustment) => ( {adjustments.map((adj: PayrollAdjustment) => (
<div key={adj.id} className="flex items-center gap-1 text-xs"> <div
<span className={adj.type === 'bonus' ? 'text-green-600' : 'text-red-600'}> key={adj.id}
{adj.type === 'bonus' ? '+' : '-'} {formatCurrency(adj.amount)} className="flex items-center gap-1 text-xs"
>
<span
className={
adj.type === 'bonus'
? 'text-green-600'
: 'text-red-600'
}
>
{adj.type === 'bonus' ? '+' : '-'}{' '}
{formatCurrency(adj.amount)}
</span> </span>
<span className="text-muted-foreground truncate max-w-[100px]"> <span className="max-w-[100px] truncate text-muted-foreground">
{adj.description} {adj.description}
</span> </span>
{payroll.status === 'unpaid' && ( {payroll.status === 'unpaid' && (
<button <button
onClick={() => handleDeleteAdjustment(adj, payroll.id)} onClick={() =>
handleDeleteAdjustment(
adj,
payroll.id,
)
}
className="text-destructive hover:text-destructive/80" className="text-destructive hover:text-destructive/80"
> >
<XCircle className="h-3 w-3" /> <XCircle className="h-3 w-3" />
@ -250,7 +285,9 @@ export function createPayrollColumns(
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleAddAdjustment(payroll)} onClick={() =>
handleAddAdjustment(payroll)
}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
@ -265,7 +302,9 @@ export function createPayrollColumns(
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handlePay(payroll)} onClick={() =>
handlePay(payroll)
}
> >
<CircleDollarSign className="h-4 w-4 text-green-600" /> <CircleDollarSign className="h-4 w-4 text-green-600" />
</Button> </Button>
@ -280,7 +319,9 @@ export function createPayrollColumns(
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleCancel(payroll)} onClick={() =>
handleCancel(payroll)
}
> >
<XCircle className="h-4 w-4 text-destructive" /> <XCircle className="h-4 w-4 text-destructive" />
</Button> </Button>

View File

@ -15,9 +15,7 @@ import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { formatCurrency } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils';
import { destroy as adjustmentDestroy } from '@/routes/admin/finance/payroll-adjustments'; import { destroy as adjustmentDestroy } from '@/routes/admin/finance/payroll-adjustments';
import { import { index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods';
index as payrollPeriodsIndex
} from '@/routes/admin/finance/payroll-periods';
import { import {
cancel as payrollCancel, cancel as payrollCancel,
pay as payrollPay, pay as payrollPay,
@ -40,31 +38,55 @@ type Props = {
}; };
const MONTH_NAMES = [ const MONTH_NAMES = [
'', 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', '',
'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember', 'Januari',
'Februari',
'Maret',
'April',
'Mei',
'Juni',
'Juli',
'Agustus',
'September',
'Oktober',
'November',
'Desember',
]; ];
export default function PayrollPeriodShow({ payrollPeriod }: Props) { export default function PayrollPeriodShow({ payrollPeriod }: Props) {
const [paying, setPaying] = useState<Payroll | null>(null); const [paying, setPaying] = useState<Payroll | null>(null);
const [cancelling, setCancelling] = useState<Payroll | null>(null); const [cancelling, setCancelling] = useState<Payroll | null>(null);
const [addingAdjustment, setAddingAdjustment] = useState<Payroll | null>(null); const [addingAdjustment, setAddingAdjustment] = useState<Payroll | null>(
const [deletingAdjustment, setDeletingAdjustment] = useState<{ adjustment: PayrollAdjustment; payrollId: number } | null>(null); null,
);
const [deletingAdjustment, setDeletingAdjustment] = useState<{
adjustment: PayrollAdjustment;
payrollId: number;
} | null>(null);
const [adjustmentType, setAdjustmentType] = useState<string>('bonus'); const [adjustmentType, setAdjustmentType] = useState<string>('bonus');
function handlePay() { function handlePay() {
if (!paying) return; if (!paying) return;
router.post(payrollPay(paying.id), {}, { router.post(
onSuccess: () => setPaying(null), payrollPay(paying.id),
}); {},
{
onSuccess: () => setPaying(null),
},
);
} }
function handleCancel() { function handleCancel() {
if (!cancelling) return; if (!cancelling) return;
router.post(payrollCancel(cancelling.id), {}, { router.post(
onSuccess: () => setCancelling(null), payrollCancel(cancelling.id),
}); {},
{
onSuccess: () => setCancelling(null),
},
);
} }
function handleDeleteAdjustment() { function handleDeleteAdjustment() {
@ -87,26 +109,45 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
}, },
}); });
const totalBaseSalary = payrollPeriod.payrolls.reduce((sum, p) => sum + p.base_salary, 0); const totalBaseSalary = payrollPeriod.payrolls.reduce(
const totalBonus = payrollPeriod.payrolls.reduce((sum, p) => sum + p.bonus_amount, 0); (sum, p) => sum + p.base_salary,
const totalDeduction = payrollPeriod.payrolls.reduce((sum, p) => sum + p.deduction_amount, 0); 0,
const totalAmount = payrollPeriod.payrolls.reduce((sum, p) => sum + p.total_amount, 0); );
const totalBonus = payrollPeriod.payrolls.reduce(
(sum, p) => sum + p.bonus_amount,
0,
);
const totalDeduction = payrollPeriod.payrolls.reduce(
(sum, p) => sum + p.deduction_amount,
0,
);
const totalAmount = payrollPeriod.payrolls.reduce(
(sum, p) => sum + p.total_amount,
0,
);
return ( return (
<> <>
<Head title={`Gaji - ${MONTH_NAMES[payrollPeriod.month]} ${payrollPeriod.year}`} /> <Head
title={`Gaji - ${MONTH_NAMES[payrollPeriod.month]} ${payrollPeriod.year}`}
/>
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6"> <div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-2xl font-semibold tracking-tight"> <h2 className="text-2xl font-semibold tracking-tight">
Gaji {MONTH_NAMES[payrollPeriod.month]} {payrollPeriod.year} Gaji {MONTH_NAMES[payrollPeriod.month]}{' '}
{payrollPeriod.year}
</h2> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{payrollPeriod.payrolls.length} karyawan &middot; Status: {payrollPeriod.status === 'open' ? 'Terbuka' : 'Ditutup'} {payrollPeriod.payrolls.length} karyawan &middot;
Status:{' '}
{payrollPeriod.status === 'open'
? 'Terbuka'
: 'Ditutup'}
</p> </p>
</div> </div>
<Button asChild variant='outline'> <Button asChild variant="outline">
<a href={payrollPeriodsIndex.url()}> <a href={payrollPeriodsIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Kembali Kembali
@ -116,20 +157,36 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
<div className="grid grid-cols-2 gap-4 md:grid-cols-4"> <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total Gaji Pokok</p> <p className="text-sm text-muted-foreground">
<p className="text-lg font-semibold">{formatCurrency(totalBaseSalary)}</p> Total Gaji Pokok
</p>
<p className="text-lg font-semibold">
{formatCurrency(totalBaseSalary)}
</p>
</div> </div>
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total Bonus</p> <p className="text-sm text-muted-foreground">
<p className="text-lg font-semibold text-green-600">{formatCurrency(totalBonus)}</p> Total Bonus
</p>
<p className="text-lg font-semibold text-green-600">
{formatCurrency(totalBonus)}
</p>
</div> </div>
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total Potongan</p> <p className="text-sm text-muted-foreground">
<p className="text-lg font-semibold text-red-600">{formatCurrency(totalDeduction)}</p> Total Potongan
</p>
<p className="text-lg font-semibold text-red-600">
{formatCurrency(totalDeduction)}
</p>
</div> </div>
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total Gaji</p> <p className="text-sm text-muted-foreground">
<p className="text-lg font-semibold">{formatCurrency(totalAmount)}</p> Total Gaji
</p>
<p className="text-lg font-semibold">
{formatCurrency(totalAmount)}
</p>
</div> </div>
</div> </div>
@ -142,12 +199,15 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
/> />
{/* Dialog Tambah Penyesuaian */} {/* Dialog Tambah Penyesuaian */}
<Dialog open={addingAdjustment !== null} onOpenChange={(open) => { <Dialog
if (!open) { open={addingAdjustment !== null}
setAddingAdjustment(null); onOpenChange={(open) => {
setAdjustmentType('bonus'); if (!open) {
} setAddingAdjustment(null);
}}> setAdjustmentType('bonus');
}
}}
>
<DialogContent> <DialogContent>
{addingAdjustment && ( {addingAdjustment && (
<Form <Form
@ -161,59 +221,108 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
{({ errors, processing }) => ( {({ errors, processing }) => (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Tambah Penyesuaian</DialogTitle> <DialogTitle>
Tambah Penyesuaian
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Jenis{' '} <span className="text-destructive">*</span> Jenis{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<input type="hidden" name="type" value={adjustmentType} /> <input
type="hidden"
name="type"
value={adjustmentType}
/>
<RadioGroup <RadioGroup
value={adjustmentType} value={adjustmentType}
onValueChange={setAdjustmentType} onValueChange={
setAdjustmentType
}
className="flex gap-4" className="flex gap-4"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RadioGroupItem value="bonus" id="bonus" /> <RadioGroupItem
<Label htmlFor="bonus" className="font-normal">Bonus</Label> value="bonus"
id="bonus"
/>
<Label
htmlFor="bonus"
className="font-normal"
>
Bonus
</Label>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RadioGroupItem value="deduction" id="deduction" /> <RadioGroupItem
<Label htmlFor="deduction" className="font-normal">Potongan</Label> value="deduction"
id="deduction"
/>
<Label
htmlFor="deduction"
className="font-normal"
>
Potongan
</Label>
</div> </div>
</RadioGroup> </RadioGroup>
<InputError message={errors.type} /> <InputError
message={errors.type}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Jumlah{' '} <span className="text-destructive">*</span> Jumlah{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<RupiahInput name="amount" min={1} /> <RupiahInput
<InputError message={errors.amount} /> name="amount"
min={1}
/>
<InputError
message={errors.amount}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="adjustment-description"> <Label htmlFor="adjustment-description">
Keterangan{' '} <span className="text-destructive">*</span> Keterangan{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="adjustment-description" id="adjustment-description"
name="description" name="description"
placeholder="Masukkan keterangan" placeholder="Masukkan keterangan"
/> />
<InputError message={errors.description} /> <InputError
message={errors.description}
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => setAddingAdjustment(null)} onClick={() =>
setAddingAdjustment(null)
}
> >
Batal Batal
</Button> </Button>
<Button type="submit" disabled={processing}> <Button
{processing ? 'Menyimpan...' : 'Simpan'} type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</> </>

View File

@ -3,12 +3,32 @@ import { LocationMap } from '@/components/location-map';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import {
import { index as attendanceIndex, store, update } from '@/routes/admin/hr/attendances'; Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
index as attendanceIndex,
store,
update,
} from '@/routes/admin/hr/attendances';
import { Head, router } from '@inertiajs/react'; import { Head, router } from '@inertiajs/react';
import { addMonths, format, subMonths } from 'date-fns'; import { addMonths, format, subMonths } from 'date-fns';
import { id } from 'date-fns/locale'; import { id } from 'date-fns/locale';
import { CalendarCheck, CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Clock, LogIn, LogOut, UserX, Wallet } from 'lucide-react'; import {
CalendarCheck,
CalendarDays,
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock,
LogIn,
LogOut,
UserX,
Wallet,
} from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -56,12 +76,20 @@ function getFirstDayOfMonth(year: number, month: number): number {
} }
function isSameDay(d1: Date, d2: Date): boolean { function isSameDay(d1: Date, d2: Date): boolean {
return d1.getDate() === d2.getDate() && d1.getMonth() === d2.getMonth() && d1.getFullYear() === d2.getFullYear(); return (
d1.getDate() === d2.getDate() &&
d1.getMonth() === d2.getMonth() &&
d1.getFullYear() === d2.getFullYear()
);
} }
function checkIsToday(date: Date): boolean { function checkIsToday(date: Date): boolean {
const t = new Date(); const t = new Date();
return date.getDate() === t.getDate() && date.getMonth() === t.getMonth() && date.getFullYear() === t.getFullYear(); return (
date.getDate() === t.getDate() &&
date.getMonth() === t.getMonth() &&
date.getFullYear() === t.getFullYear()
);
} }
function isWeekend(date: Date): boolean { function isWeekend(date: Date): boolean {
@ -69,7 +97,11 @@ function isWeekend(date: Date): boolean {
return day === 0 || day === 6; return day === 0 || day === 6;
} }
function isLate(checkInAt: string | null, officeHour: number, officeMinute: number): boolean { function isLate(
checkInAt: string | null,
officeHour: number,
officeMinute: number,
): boolean {
if (!checkInAt) return false; if (!checkInAt) return false;
const d = new Date(checkInAt); const d = new Date(checkInAt);
const h = d.getHours(); const h = d.getHours();
@ -77,7 +109,11 @@ function isLate(checkInAt: string | null, officeHour: number, officeMinute: numb
return h > officeHour || (h === officeHour && m > officeMinute); return h > officeHour || (h === officeHour && m > officeMinute);
} }
function getLateMinutes(checkInAt: string | null, officeHour: number, officeMinute: number): number { function getLateMinutes(
checkInAt: string | null,
officeHour: number,
officeMinute: number,
): number {
if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) return 0; if (!checkInAt || !isLate(checkInAt, officeHour, officeMinute)) return 0;
const d = new Date(checkInAt); const d = new Date(checkInAt);
const officeStart = new Date(d); const officeStart = new Date(d);
@ -93,14 +129,29 @@ function formatMinutes(minutes: number | null): string {
return `${hours} jam ${mins} menit`; return `${hours} jam ${mins} menit`;
} }
export default function AttendanceIndex({ attendances, todayAttendance, currentYear, currentMonth, monthStats, hrSettings }: Props) { export default function AttendanceIndex({
const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time.split(':').map(Number); attendances,
const [viewDate, setViewDate] = useState(new Date(currentYear, currentMonth - 1)); todayAttendance,
currentYear,
currentMonth,
monthStats,
hrSettings,
}: Props) {
const [officeHour, officeMinute] = hrSettings.scheduled_check_in_time
.split(':')
.map(Number);
const [viewDate, setViewDate] = useState(
new Date(currentYear, currentMonth - 1),
);
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [showCamera, setShowCamera] = useState(false); const [showCamera, setShowCamera] = useState(false);
const [actionType, setActionType] = useState<'check-in' | 'check-out'>('check-in'); const [actionType, setActionType] = useState<'check-in' | 'check-out'>(
'check-in',
);
const [locationLoading, setLocationLoading] = useState(false); const [locationLoading, setLocationLoading] = useState(false);
const [detailAttendance, setDetailAttendance] = useState<Attendance | null>(null); const [detailAttendance, setDetailAttendance] = useState<Attendance | null>(
null,
);
const viewYear = viewDate.getFullYear(); const viewYear = viewDate.getFullYear();
const viewMonth = viewDate.getMonth() + 1; const viewMonth = viewDate.getMonth() + 1;
@ -121,7 +172,10 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
const calendarDays = useMemo(() => { const calendarDays = useMemo(() => {
const daysInMonth = getDaysInMonth(viewYear, viewMonth); const daysInMonth = getDaysInMonth(viewYear, viewMonth);
const firstDay = getFirstDayOfMonth(viewYear, viewMonth); const firstDay = getFirstDayOfMonth(viewYear, viewMonth);
const prevMonthDays = getDaysInMonth(viewYear, viewMonth === 1 ? 12 : viewMonth - 1); const prevMonthDays = getDaysInMonth(
viewYear,
viewMonth === 1 ? 12 : viewMonth - 1,
);
const days: { day: number; isCurrentMonth: boolean; date: Date }[] = []; const days: { day: number; isCurrentMonth: boolean; date: Date }[] = [];
@ -129,18 +183,30 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
const d = prevMonthDays - i; const d = prevMonthDays - i;
const m = viewMonth === 1 ? 12 : viewMonth - 1; const m = viewMonth === 1 ? 12 : viewMonth - 1;
const y = viewMonth === 1 ? viewYear - 1 : viewYear; const y = viewMonth === 1 ? viewYear - 1 : viewYear;
days.push({ day: d, isCurrentMonth: false, date: new Date(y, m - 1, d) }); days.push({
day: d,
isCurrentMonth: false,
date: new Date(y, m - 1, d),
});
} }
for (let i = 1; i <= daysInMonth; i++) { for (let i = 1; i <= daysInMonth; i++) {
days.push({ day: i, isCurrentMonth: true, date: new Date(viewYear, viewMonth - 1, i) }); days.push({
day: i,
isCurrentMonth: true,
date: new Date(viewYear, viewMonth - 1, i),
});
} }
const remaining = 42 - days.length; const remaining = 42 - days.length;
for (let i = 1; i <= remaining; i++) { for (let i = 1; i <= remaining; i++) {
const m = viewMonth === 12 ? 1 : viewMonth + 1; const m = viewMonth === 12 ? 1 : viewMonth + 1;
const y = viewMonth === 12 ? viewYear + 1 : viewYear; const y = viewMonth === 12 ? viewYear + 1 : viewYear;
days.push({ day: i, isCurrentMonth: false, date: new Date(y, m - 1, i) }); days.push({
day: i,
isCurrentMonth: false,
date: new Date(y, m - 1, i),
});
} }
return days; return days;
@ -164,20 +230,30 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
setLocationLoading(false); setLocationLoading(false);
const formData = new FormData(); const formData = new FormData();
formData.append('photo', dataUrl); formData.append('photo', dataUrl);
formData.append('latitude', position.coords.latitude.toString()); formData.append(
formData.append('longitude', position.coords.longitude.toString()); 'latitude',
position.coords.latitude.toString(),
);
formData.append(
'longitude',
position.coords.longitude.toString(),
);
if (actionType === 'check-in') { if (actionType === 'check-in') {
router.post(store(), formData, { preserveScroll: true }); router.post(store(), formData, { preserveScroll: true });
} else if (actionType === 'check-out' && todayAttendance) { } else if (actionType === 'check-out' && todayAttendance) {
router.put(update(todayAttendance.id), formData, { preserveScroll: true }); router.put(update(todayAttendance.id), formData, {
preserveScroll: true,
});
} }
}, },
() => { () => {
setLocationLoading(false); setLocationLoading(false);
toast.error('Gagal mendapatkan lokasi. Pastikan izin lokasi diberikan.'); toast.error(
'Gagal mendapatkan lokasi. Pastikan izin lokasi diberikan.',
);
}, },
{ enableHighAccuracy: true, timeout: 10000 } { enableHighAccuracy: true, timeout: 10000 },
); );
}; };
@ -205,7 +281,9 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6"> <div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div> <div>
<h2 className="text-2xl font-semibold tracking-tight">Presensi</h2> <h2 className="text-2xl font-semibold tracking-tight">
Presensi
</h2>
</div> </div>
{/* Alert Status Presensi */} {/* Alert Status Presensi */}
@ -219,33 +297,53 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" /> <CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
<span className="text-muted-foreground">Masuk:</span> <span className="text-muted-foreground">
<span className="font-medium">{formatTime(todayAttendance?.check_in_at)}</span> Masuk:
</span>
<span className="font-medium">
{formatTime(
todayAttendance?.check_in_at,
)}
</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{hasCheckedOut ? ( {hasCheckedOut ? (
<> <>
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" /> <CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
<span className="text-muted-foreground">Pulang:</span> <span className="text-muted-foreground">
<span className="font-medium">{formatTime(todayAttendance?.check_out_at)}</span> Pulang:
</span>
<span className="font-medium">
{formatTime(
todayAttendance?.check_out_at,
)}
</span>
</> </>
) : ( ) : (
<> <>
<Clock className="h-3.5 w-3.5 text-orange-500" /> <Clock className="h-3.5 w-3.5 text-orange-500" />
<span className="text-muted-foreground">Pulang:</span> <span className="text-muted-foreground">
<span className="font-medium text-orange-600">Belum</span> Pulang:
</span>
<span className="font-medium text-orange-600">
Belum
</span>
</> </>
)} )}
</div> </div>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground">Anda belum melakukan presensi hari ini</p> <p className="text-sm text-muted-foreground">
Anda belum melakukan presensi hari ini
</p>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{locationLoading && ( {locationLoading && (
<span className="text-xs text-muted-foreground">Mendapatkan lokasi...</span> <span className="text-xs text-muted-foreground">
Mendapatkan lokasi...
</span>
)} )}
<Button <Button
size="sm" size="sm"
@ -259,7 +357,11 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
size="sm" size="sm"
variant="outline" variant="outline"
onClick={handleCheckOut} onClick={handleCheckOut}
disabled={!hasCheckedIn || hasCheckedOut || locationLoading} disabled={
!hasCheckedIn ||
hasCheckedOut ||
locationLoading
}
> >
<LogOut className="mr-1.5 h-3.5 w-3.5" /> <LogOut className="mr-1.5 h-3.5 w-3.5" />
Presensi Pulang Presensi Pulang
@ -277,8 +379,12 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
<CalendarDays className="h-5 w-5 text-blue-600" /> <CalendarDays className="h-5 w-5 text-blue-600" />
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground">Hari Kerja</p> <p className="text-xs text-muted-foreground">
<p className="text-lg font-bold">{monthStats.working_days}</p> Hari Kerja
</p>
<p className="text-lg font-bold">
{monthStats.working_days}
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -288,8 +394,12 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
<CheckCircle2 className="h-5 w-5 text-green-600" /> <CheckCircle2 className="h-5 w-5 text-green-600" />
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground">Hadir</p> <p className="text-xs text-muted-foreground">
<p className="text-lg font-bold">{monthStats.present}</p> Hadir
</p>
<p className="text-lg font-bold">
{monthStats.present}
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -299,8 +409,12 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
<UserX className="h-5 w-5 text-red-600" /> <UserX className="h-5 w-5 text-red-600" />
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground">Tidak Hadir</p> <p className="text-xs text-muted-foreground">
<p className="text-lg font-bold">{monthStats.absent}</p> Tidak Hadir
</p>
<p className="text-lg font-bold">
{monthStats.absent}
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -310,41 +424,72 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
<Wallet className="h-5 w-5 text-amber-600" /> <Wallet className="h-5 w-5 text-amber-600" />
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground">Cuti</p> <p className="text-xs text-muted-foreground">
<p className="text-lg font-bold">{monthStats.leave}</p> Cuti
</p>
<p className="text-lg font-bold">
{monthStats.leave}
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<Card className="overflow-hidden" style={{ '--card-spacing': '0px' } as React.CSSProperties}> <Card
className="overflow-hidden"
style={{ '--card-spacing': '0px' } as React.CSSProperties}
>
{/* Calendar Header */} {/* Calendar Header */}
<div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex flex-col items-center overflow-hidden rounded-lg ring-1 ring-border"> <div className="flex flex-col items-center overflow-hidden rounded-lg ring-1 ring-border">
<div className="bg-muted px-3 py-0.5"> <div className="bg-muted px-3 py-0.5">
<span className="text-xs font-semibold uppercase text-muted-foreground"> <span className="text-xs font-semibold text-muted-foreground uppercase">
{format(viewDate, 'MMM', { locale: id })} {format(viewDate, 'MMM', {
locale: id,
})}
</span> </span>
</div> </div>
<div className="px-3 py-1"> <div className="px-3 py-1">
<span className="text-lg font-bold text-primary">{format(viewDate, 'dd')}</span> <span className="text-lg font-bold text-primary">
{format(viewDate, 'dd')}
</span>
</div> </div>
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold text-foreground"> <h3 className="text-lg font-semibold text-foreground">
{format(viewDate, 'MMMM yyyy', { locale: id })} {format(viewDate, 'MMMM yyyy', {
locale: id,
})}
</h3> </h3>
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handlePrevMonth}> <Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handlePrevMonth}
>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="sm" className="h-8 px-3 text-xs font-medium" onClick={() => { setViewDate(new Date()); setSelectedDate(new Date()); }}> <Button
variant="ghost"
size="sm"
className="h-8 px-3 text-xs font-medium"
onClick={() => {
setViewDate(new Date());
setSelectedDate(new Date());
}}
>
Hari ini Hari ini
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleNextMonth}> <Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleNextMonth}
>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
@ -353,7 +498,10 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
{/* Weekday Headers */} {/* Weekday Headers */}
<div className="grid grid-cols-7 border-b"> <div className="grid grid-cols-7 border-b">
{WEEKDAYS.map((day) => ( {WEEKDAYS.map((day) => (
<div key={day} className="flex items-center justify-center py-3 text-xs font-medium text-muted-foreground"> <div
key={day}
className="flex items-center justify-center py-3 text-xs font-medium text-muted-foreground"
>
{day} {day}
</div> </div>
))} ))}
@ -364,8 +512,14 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
{calendarDays.map((cell, idx) => { {calendarDays.map((cell, idx) => {
const dateStr = format(cell.date, 'yyyy-MM-dd'); const dateStr = format(cell.date, 'yyyy-MM-dd');
const attendance = attendanceDates.get(dateStr); const attendance = attendanceDates.get(dateStr);
const isSelected = isSameDay(cell.date, selectedDate); const isSelected = isSameDay(
const isTodayDate = isSameDay(cell.date, new Date()); cell.date,
selectedDate,
);
const isTodayDate = isSameDay(
cell.date,
new Date(),
);
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
@ -373,20 +527,39 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
cellDate.setHours(0, 0, 0, 0); cellDate.setHours(0, 0, 0, 0);
const isPastDate = cellDate < today; const isPastDate = cellDate < today;
const showAbsent = cell.isCurrentMonth && isPastDate && !isWeekend(cell.date) && !attendance; const showAbsent =
cell.isCurrentMonth &&
isPastDate &&
!isWeekend(cell.date) &&
!attendance;
const late = attendance ? isLate(attendance.check_in_at, officeHour, officeMinute) : false; const late = attendance
const lateMins = attendance ? getLateMinutes(attendance.check_in_at, officeHour, officeMinute) : 0; ? isLate(
attendance.check_in_at,
officeHour,
officeMinute,
)
: false;
const lateMins = attendance
? getLateMinutes(
attendance.check_in_at,
officeHour,
officeMinute,
)
: 0;
return ( return (
<button <button
key={idx} key={idx}
onClick={() => { onClick={() => {
setSelectedDate(cell.date); setSelectedDate(cell.date);
if (attendance) setDetailAttendance(attendance); if (attendance)
setDetailAttendance(attendance);
}} }}
className={`group relative flex min-h-[100px] flex-col p-2 text-left transition-colors hover:bg-muted/50 ${ className={`group relative flex min-h-[100px] flex-col p-2 text-left transition-colors hover:bg-muted/50 ${
!cell.isCurrentMonth ? 'bg-muted/30 text-muted-foreground/50' : '' !cell.isCurrentMonth
? 'bg-muted/30 text-muted-foreground/50'
: ''
}`} }`}
style={{ style={{
borderRight: '1px solid var(--border)', borderRight: '1px solid var(--border)',
@ -399,8 +572,8 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
isSelected isSelected
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'
: isTodayDate : isTodayDate
? 'bg-muted text-foreground' ? 'bg-muted text-foreground'
: 'text-foreground' : 'text-foreground'
}`} }`}
> >
{cell.day} {cell.day}
@ -409,15 +582,40 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
<div className="mt-1 flex flex-col gap-0.5"> <div className="mt-1 flex flex-col gap-0.5">
{attendance && ( {attendance && (
<> <>
<span className={`inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${late ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}> <span
{late ? 'Terlambat' : 'Hadir'} className={`inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold ${late ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}
>
{late
? 'Terlambat'
: 'Hadir'}
</span>
<span className="text-[10px] text-muted-foreground">
Masuk :{' '}
{formatTime(
attendance.check_in_at,
)}
</span>
<span className="text-[10px] text-muted-foreground">
Pulang :{' '}
{formatTime(
attendance.check_out_at,
)}
</span> </span>
<span className="text-[10px] text-muted-foreground">Masuk : {formatTime(attendance.check_in_at)}</span>
<span className="text-[10px] text-muted-foreground">Pulang : {formatTime(attendance.check_out_at)}</span>
{late && ( {late && (
<span className="text-[10px] text-yellow-600">Telat : {formatMinutes(lateMins)} menit</span> <span className="text-[10px] text-yellow-600">
Telat :{' '}
{formatMinutes(
lateMins,
)}{' '}
menit
</span>
)} )}
<span className="text-[10px] text-muted-foreground">Jam Kerja : {formatMinutes(attendance.work_duration_minutes)}</span> <span className="text-[10px] text-muted-foreground">
Jam Kerja :{' '}
{formatMinutes(
attendance.work_duration_minutes,
)}
</span>
</> </>
)} )}
{showAbsent && ( {showAbsent && (
@ -440,21 +638,34 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
/> />
)} )}
<Dialog open={!!detailAttendance} onOpenChange={(open) => !open && setDetailAttendance(null)}> <Dialog
open={!!detailAttendance}
onOpenChange={(open) => !open && setDetailAttendance(null)}
>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Detail Presensi - {detailAttendance && format(new Date(detailAttendance.attendance_date), 'dd MMMM yyyy', { locale: id })} Detail Presensi -{' '}
{detailAttendance &&
format(
new Date(detailAttendance.attendance_date),
'dd MMMM yyyy',
{ locale: id },
)}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
{detailAttendance && ( {detailAttendance && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Foto Masuk</span> <span className="text-sm font-medium text-muted-foreground">
Foto Masuk
</span>
{detailAttendance.check_in_photo ? ( {detailAttendance.check_in_photo ? (
<img <img
src={detailAttendance.check_in_photo} src={
detailAttendance.check_in_photo
}
alt="Foto Masuk" alt="Foto Masuk"
className="w-full rounded-lg border object-cover" className="w-full rounded-lg border object-cover"
/> />
@ -465,14 +676,22 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
)} )}
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<LogIn className="h-4 w-4 text-green-600" /> <LogIn className="h-4 w-4 text-green-600" />
<span className="font-medium">{formatTime(detailAttendance.check_in_at)}</span> <span className="font-medium">
{formatTime(
detailAttendance.check_in_at,
)}
</span>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Foto Pulang</span> <span className="text-sm font-medium text-muted-foreground">
Foto Pulang
</span>
{detailAttendance.check_out_photo ? ( {detailAttendance.check_out_photo ? (
<img <img
src={detailAttendance.check_out_photo} src={
detailAttendance.check_out_photo
}
alt="Foto Pulang" alt="Foto Pulang"
className="w-full rounded-lg border object-cover" className="w-full rounded-lg border object-cover"
/> />
@ -485,22 +704,34 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
{detailAttendance.check_out_at ? ( {detailAttendance.check_out_at ? (
<> <>
<LogOut className="h-4 w-4 text-blue-600" /> <LogOut className="h-4 w-4 text-blue-600" />
<span className="font-medium">{formatTime(detailAttendance.check_out_at)}</span> <span className="font-medium">
{formatTime(
detailAttendance.check_out_at,
)}
</span>
</> </>
) : ( ) : (
<> <>
<Clock className="h-4 w-4 text-orange-500" /> <Clock className="h-4 w-4 text-orange-500" />
<span className="font-medium text-orange-600">Belum pulang</span> <span className="font-medium text-orange-600">
Belum pulang
</span>
</> </>
)} )}
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Lokasi Presensi</span> <span className="text-sm font-medium text-muted-foreground">
Lokasi Presensi
</span>
<LocationMap <LocationMap
latitude={detailAttendance.check_in_latitude} latitude={
longitude={detailAttendance.check_in_longitude} detailAttendance.check_in_latitude
}
longitude={
detailAttendance.check_in_longitude
}
height="200px" height="200px"
/> />
</div> </div>

View File

@ -56,16 +56,19 @@ type CreateColumnsParams = {
export function createEmployeeColumns( export function createEmployeeColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<Employee>[] { ): ColumnDef<Employee>[] {
const { handleEdit, handleDeleteClick, handleResetPassword, toggleActiveUrl } = params; const {
handleEdit,
handleDeleteClick,
handleResetPassword,
toggleActiveUrl,
} = params;
return [ return [
{ {
id: 'no', id: 'no',
header: () => <span className="block text-center">No</span>, header: () => <span className="block text-center">No</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block text-center"> <span className="block text-center">{row.index + 1}</span>
{row.index + 1}
</span>
), ),
meta: { meta: {
className: 'w-[50px] text-center', className: 'w-[50px] text-center',
@ -80,9 +83,7 @@ export function createEmployeeColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Nama</span> <span>Nama</span>
@ -111,9 +112,7 @@ export function createEmployeeColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Username</span> <span>Username</span>
@ -133,7 +132,9 @@ export function createEmployeeColumns(
cell: ({ row }) => { cell: ({ row }) => {
const employee = row.original; const employee = row.original;
return <span>{employee.user_profile?.phone_number ?? '-'}</span>; return (
<span>{employee.user_profile?.phone_number ?? '-'}</span>
);
}, },
}, },
{ {
@ -144,9 +145,7 @@ export function createEmployeeColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Status</span> <span>Status</span>
@ -158,7 +157,9 @@ export function createEmployeeColumns(
return ( return (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium"> <span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
{getEmploymentStatusLabel(employee.employee?.employment_status ?? '')} {getEmploymentStatusLabel(
employee.employee?.employment_status ?? '',
)}
</span> </span>
); );
}, },
@ -179,9 +180,13 @@ export function createEmployeeColumns(
size="sm" size="sm"
checked={employee.is_active} checked={employee.is_active}
onCheckedChange={() => { onCheckedChange={() => {
router.post(toggleActiveUrl(employee.id), {}, { router.post(
preserveScroll: true, toggleActiveUrl(employee.id),
}); {},
{
preserveScroll: true,
},
);
}} }}
/> />
</div> </div>
@ -211,9 +216,7 @@ export function createEmployeeColumns(
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">Edit</TooltipContent>
Edit
</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
@ -221,7 +224,9 @@ export function createEmployeeColumns(
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleResetPassword(employee)} onClick={() =>
handleResetPassword(employee)
}
> >
<KeyRound className="h-4 w-4 text-muted-foreground" /> <KeyRound className="h-4 w-4 text-muted-foreground" />
</Button> </Button>
@ -236,7 +241,9 @@ export function createEmployeeColumns(
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleDeleteClick(employee)} onClick={() =>
handleDeleteClick(employee)
}
> >
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
</Button> </Button>

View File

@ -36,7 +36,7 @@ export default function EmployeeCreate() {
Tambah Pegawai Tambah Pegawai
</h2> </h2>
</div> </div>
<Button asChild variant='outline'> <Button asChild variant="outline">
<a href={employeeIndex.url()}> <a href={employeeIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Kembali Kembali
@ -52,9 +52,7 @@ export default function EmployeeCreate() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Form <Form action={store()}>
action={store()}
>
{({ errors, processing }) => ( {({ errors, processing }) => (
<> <>
<div className="grid gap-6"> <div className="grid gap-6">
@ -65,7 +63,10 @@ export default function EmployeeCreate() {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="email"> <Label htmlFor="email">
Email <span className="text-destructive">*</span> Email{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="email" id="email"
@ -73,18 +74,25 @@ export default function EmployeeCreate() {
type="email" type="email"
placeholder="Masukkan email" placeholder="Masukkan email"
/> />
<InputError message={errors.email} /> <InputError
message={errors.email}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="username"> <Label htmlFor="username">
Username <span className="text-destructive">*</span> Username{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="username" id="username"
name="username" name="username"
placeholder="Masukkan username" placeholder="Masukkan username"
/> />
<InputError message={errors.username} /> <InputError
message={errors.username}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -96,35 +104,63 @@ export default function EmployeeCreate() {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="full_name"> <Label htmlFor="full_name">
Nama Lengkap <span className="text-destructive">*</span> Nama Lengkap{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="full_name" id="full_name"
name="full_name" name="full_name"
placeholder="Masukkan nama lengkap" placeholder="Masukkan nama lengkap"
/> />
<InputError message={errors.full_name} /> <InputError
message={errors.full_name}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="phone_number"> <Label htmlFor="phone_number">
No. Telepon No. Telepon
</Label> </Label>
<PhoneNumberInput name="phone_number"/> <PhoneNumberInput name="phone_number" />
<InputError message={errors.phone_number} /> <InputError
message={errors.phone_number}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Jenis Kelamin</Label> <Label>Jenis Kelamin</Label>
<RadioGroup name="gender" className="flex gap-4"> <RadioGroup
name="gender"
className="flex gap-4"
>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="male" id="gender-male" /> <RadioGroupItem
<Label htmlFor="gender-male" className="font-normal">Laki-laki</Label> value="male"
id="gender-male"
/>
<Label
htmlFor="gender-male"
className="font-normal"
>
Laki-laki
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="female" id="gender-female" /> <RadioGroupItem
<Label htmlFor="gender-female" className="font-normal">Perempuan</Label> value="female"
id="gender-female"
/>
<Label
htmlFor="gender-female"
className="font-normal"
>
Perempuan
</Label>
</div> </div>
</RadioGroup> </RadioGroup>
<InputError message={errors.gender} /> <InputError
message={errors.gender}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Tanggal Lahir</Label> <Label>Tanggal Lahir</Label>
@ -134,7 +170,9 @@ export default function EmployeeCreate() {
onChange={() => {}} onChange={() => {}}
placeholder="Pilih tanggal lahir" placeholder="Pilih tanggal lahir"
/> />
<InputError message={errors.birth_date} /> <InputError
message={errors.birth_date}
/>
</div> </div>
<div className="grid gap-2 md:col-span-2"> <div className="grid gap-2 md:col-span-2">
<Label htmlFor="address"> <Label htmlFor="address">
@ -146,7 +184,9 @@ export default function EmployeeCreate() {
placeholder="Masukkan alamat" placeholder="Masukkan alamat"
rows={3} rows={3}
/> />
<InputError message={errors.address} /> <InputError
message={errors.address}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -157,14 +197,21 @@ export default function EmployeeCreate() {
</CardHeader> </CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Tanggal Masuk <span className="text-destructive">*</span></Label> <Label>
Tanggal Masuk{' '}
<span className="text-destructive">
*
</span>
</Label>
<DatePicker <DatePicker
name="join_date" name="join_date"
value={joinDate} value={joinDate}
onChange={setJoinDate} onChange={setJoinDate}
placeholder="Pilih tanggal masuk" placeholder="Pilih tanggal masuk"
/> />
<InputError message={errors.join_date} /> <InputError
message={errors.join_date}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Tanggal Keluar</Label> <Label>Tanggal Keluar</Label>
@ -174,36 +221,68 @@ export default function EmployeeCreate() {
onChange={setResignDate} onChange={setResignDate}
placeholder="Pilih tanggal keluar" placeholder="Pilih tanggal keluar"
/> />
<InputError message={errors.resign_date} /> <InputError
message={errors.resign_date}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Status Kepegawaian <span className="text-destructive">*</span></Label> <Label>
<Select name="employment_status" defaultValue="full_time"> Status Kepegawaian{' '}
<span className="text-destructive">
*
</span>
</Label>
<Select
name="employment_status"
defaultValue="full_time"
>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Pilih status kepegawaian" /> <SelectValue placeholder="Pilih status kepegawaian" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="full_time">Full Time</SelectItem> <SelectItem value="full_time">
<SelectItem value="part_time">Part Time</SelectItem> Full Time
<SelectItem value="contract">Kontrak</SelectItem> </SelectItem>
<SelectItem value="internship">Magang</SelectItem> <SelectItem value="part_time">
<SelectItem value="resigned">Keluar</SelectItem> Part Time
</SelectItem>
<SelectItem value="contract">
Kontrak
</SelectItem>
<SelectItem value="internship">
Magang
</SelectItem>
<SelectItem value="resigned">
Keluar
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<InputError message={errors.employment_status} /> <InputError
message={
errors.employment_status
}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="base_salary"> <Label htmlFor="base_salary">
Gaji Pokok <span className="text-destructive">*</span> Gaji Pokok{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<RupiahInput name="base_salary" min={1} /> <RupiahInput
<InputError message={errors.base_salary} /> name="base_salary"
min={1}
/>
<InputError
message={errors.base_salary}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<div className="flex items-center gap-4 mt-6"> <div className="mt-6 flex items-center gap-4">
<Button type="submit" disabled={processing}> <Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'} {processing ? 'Menyimpan...' : 'Simpan'}
</Button> </Button>

View File

@ -45,13 +45,19 @@ type Props = {
export default function EmployeeEdit({ employee }: Props) { export default function EmployeeEdit({ employee }: Props) {
const [joinDate, setJoinDate] = useState<Date | undefined>( const [joinDate, setJoinDate] = useState<Date | undefined>(
employee.employee?.join_date ? new Date(employee.employee.join_date) : undefined employee.employee?.join_date
? new Date(employee.employee.join_date)
: undefined,
); );
const [resignDate, setResignDate] = useState<Date | undefined>( const [resignDate, setResignDate] = useState<Date | undefined>(
employee.employee?.resign_date ? new Date(employee.employee.resign_date) : undefined employee.employee?.resign_date
? new Date(employee.employee.resign_date)
: undefined,
); );
const [birthDate, setBirthDate] = useState<Date | undefined>( const [birthDate, setBirthDate] = useState<Date | undefined>(
employee.user_profile?.birth_date ? new Date(employee.user_profile.birth_date) : undefined employee.user_profile?.birth_date
? new Date(employee.user_profile.birth_date)
: undefined,
); );
return ( return (
@ -65,7 +71,7 @@ export default function EmployeeEdit({ employee }: Props) {
Edit Pegawai Edit Pegawai
</h2> </h2>
</div> </div>
<Button asChild variant='outline'> <Button asChild variant="outline">
<a href={employeeIndex.url()}> <a href={employeeIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Kembali Kembali
@ -87,7 +93,10 @@ export default function EmployeeEdit({ employee }: Props) {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="email"> <Label htmlFor="email">
Email <span className="text-destructive">*</span> Email{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="email" id="email"
@ -96,11 +105,16 @@ export default function EmployeeEdit({ employee }: Props) {
placeholder="Masukkan email" placeholder="Masukkan email"
defaultValue={employee.email} defaultValue={employee.email}
/> />
<InputError message={errors.email} /> <InputError
message={errors.email}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="username"> <Label htmlFor="username">
Username <span className="text-destructive">*</span> Username{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="username" id="username"
@ -108,7 +122,9 @@ export default function EmployeeEdit({ employee }: Props) {
placeholder="Masukkan username" placeholder="Masukkan username"
defaultValue={employee.username} defaultValue={employee.username}
/> />
<InputError message={errors.username} /> <InputError
message={errors.username}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -120,36 +136,71 @@ export default function EmployeeEdit({ employee }: Props) {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="full_name"> <Label htmlFor="full_name">
Nama Lengkap <span className="text-destructive">*</span> Nama Lengkap{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="full_name" id="full_name"
name="full_name" name="full_name"
placeholder="Masukkan nama lengkap" placeholder="Masukkan nama lengkap"
defaultValue={employee.user_profile?.full_name ?? ''} defaultValue={
employee.user_profile
?.full_name ?? ''
}
/>
<InputError
message={errors.full_name}
/> />
<InputError message={errors.full_name} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="phone_number"> <Label htmlFor="phone_number">
No. Telepon No. Telepon
</Label> </Label>
<PhoneNumberInput name="phone_number"/> <PhoneNumberInput name="phone_number" />
<InputError message={errors.phone_number} /> <InputError
message={errors.phone_number}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Jenis Kelamin</Label> <Label>Jenis Kelamin</Label>
<RadioGroup name="gender" defaultValue={employee.user_profile?.gender ?? ''} className="flex gap-4"> <RadioGroup
name="gender"
defaultValue={
employee.user_profile
?.gender ?? ''
}
className="flex gap-4"
>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="male" id="gender-male" /> <RadioGroupItem
<Label htmlFor="gender-male" className="font-normal">Laki-laki</Label> value="male"
id="gender-male"
/>
<Label
htmlFor="gender-male"
className="font-normal"
>
Laki-laki
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="female" id="gender-female" /> <RadioGroupItem
<Label htmlFor="gender-female" className="font-normal">Perempuan</Label> value="female"
id="gender-female"
/>
<Label
htmlFor="gender-female"
className="font-normal"
>
Perempuan
</Label>
</div> </div>
</RadioGroup> </RadioGroup>
<InputError message={errors.gender} /> <InputError
message={errors.gender}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Tanggal Lahir</Label> <Label>Tanggal Lahir</Label>
@ -159,7 +210,9 @@ export default function EmployeeEdit({ employee }: Props) {
onChange={setBirthDate} onChange={setBirthDate}
placeholder="Pilih tanggal lahir" placeholder="Pilih tanggal lahir"
/> />
<InputError message={errors.birth_date} /> <InputError
message={errors.birth_date}
/>
</div> </div>
<div className="grid gap-2 md:col-span-2"> <div className="grid gap-2 md:col-span-2">
<Label htmlFor="address"> <Label htmlFor="address">
@ -170,9 +223,14 @@ export default function EmployeeEdit({ employee }: Props) {
name="address" name="address"
placeholder="Masukkan alamat" placeholder="Masukkan alamat"
rows={3} rows={3}
defaultValue={employee.user_profile?.address ?? ''} defaultValue={
employee.user_profile
?.address ?? ''
}
/>
<InputError
message={errors.address}
/> />
<InputError message={errors.address} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -183,14 +241,21 @@ export default function EmployeeEdit({ employee }: Props) {
</CardHeader> </CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Tanggal Masuk <span className="text-destructive">*</span></Label> <Label>
Tanggal Masuk{' '}
<span className="text-destructive">
*
</span>
</Label>
<DatePicker <DatePicker
name="join_date" name="join_date"
value={joinDate} value={joinDate}
onChange={setJoinDate} onChange={setJoinDate}
placeholder="Pilih tanggal masuk" placeholder="Pilih tanggal masuk"
/> />
<InputError message={errors.join_date} /> <InputError
message={errors.join_date}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Tanggal Keluar</Label> <Label>Tanggal Keluar</Label>
@ -200,36 +265,72 @@ export default function EmployeeEdit({ employee }: Props) {
onChange={setResignDate} onChange={setResignDate}
placeholder="Pilih tanggal keluar" placeholder="Pilih tanggal keluar"
/> />
<InputError message={errors.resign_date} /> <InputError
message={errors.resign_date}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Status Kepegawaian <span className="text-destructive">*</span></Label> <Label>
<Select name="employment_status" defaultValue={employee.employee?.employment_status ?? 'full_time'}> Status Kepegawaian{' '}
<span className="text-destructive">
*
</span>
</Label>
<Select
name="employment_status"
defaultValue={
employee.employee
?.employment_status ??
'full_time'
}
>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Pilih status kepegawaian" /> <SelectValue placeholder="Pilih status kepegawaian" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="full_time">Full Time</SelectItem> <SelectItem value="full_time">
<SelectItem value="part_time">Part Time</SelectItem> Full Time
<SelectItem value="contract">Kontrak</SelectItem> </SelectItem>
<SelectItem value="internship">Magang</SelectItem> <SelectItem value="part_time">
<SelectItem value="resigned">Keluar</SelectItem> Part Time
</SelectItem>
<SelectItem value="contract">
Kontrak
</SelectItem>
<SelectItem value="internship">
Magang
</SelectItem>
<SelectItem value="resigned">
Keluar
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<InputError message={errors.employment_status} /> <InputError
message={
errors.employment_status
}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="base_salary"> <Label htmlFor="base_salary">
Gaji Pokok <span className="text-destructive">*</span> Gaji Pokok{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<RupiahInput name="base_salary" min={1} /> <RupiahInput
<InputError message={errors.base_salary} /> name="base_salary"
min={1}
/>
<InputError
message={errors.base_salary}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<div className="flex items-center gap-4 mt-6"> <div className="mt-6 flex items-center gap-4">
<Button type="submit" disabled={processing}> <Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'} {processing ? 'Menyimpan...' : 'Simpan'}
</Button> </Button>

View File

@ -5,9 +5,26 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import type { PaginationState, SortState } from '@/components/data-table'; import type { PaginationState, SortState } from '@/components/data-table';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; Popover,
import { destroy, create as employeeCreate, edit as employeeEdit, index as employeeIndex, toggleActive, resetPassword as resetPasswordRoute } from '@/routes/admin/hr/employees'; PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
destroy,
create as employeeCreate,
edit as employeeEdit,
index as employeeIndex,
toggleActive,
resetPassword as resetPasswordRoute,
} from '@/routes/admin/hr/employees';
import type { Employee } from './columns'; import type { Employee } from './columns';
import { createEmployeeColumns } from './columns'; import { createEmployeeColumns } from './columns';
@ -28,10 +45,14 @@ type Props = {
export default function EmployeeIndex({ employees, filters }: Props) { export default function EmployeeIndex({ employees, filters }: Props) {
const [deleting, setDeleting] = useState<Employee | null>(null); const [deleting, setDeleting] = useState<Employee | null>(null);
const [resetPasswordTarget, setResetPasswordTarget] = useState<Employee | null>(null); const [resetPasswordTarget, setResetPasswordTarget] =
useState<Employee | null>(null);
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' }); const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = { const pagination: PaginationState = {
current_page: employees.current_page, current_page: employees.current_page,
@ -51,71 +72,98 @@ export default function EmployeeIndex({ employees, filters }: Props) {
newFilters[key as keyof typeof newFilters] = value; newFilters[key as keyof typeof newFilters] = value;
} }
router.get(employeeIndex.url(), { router.get(
...newFilters, employeeIndex.url(),
page: 1, {
per_page: pagination.per_page, ...newFilters,
search, page: 1,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
function clearFilters() { function clearFilters() {
router.get(employeeIndex.url(), { router.get(
page: 1, employeeIndex.url(),
per_page: pagination.per_page, {
search, page: 1,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
setFilterOpen(false); setFilterOpen(false);
} }
function handlePageChange(page: number) { function handlePageChange(page: number) {
router.get(employeeIndex.url(), { router.get(
...filters, employeeIndex.url(),
page, {
per_page: pagination.per_page, ...filters,
search, page,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
function handlePerPageChange(perPage: number) { function handlePerPageChange(perPage: number) {
router.get(employeeIndex.url(), { router.get(
...filters, employeeIndex.url(),
page: 1, {
per_page: perPage, ...filters,
search, page: 1,
sort: sort.column, per_page: perPage,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
const handleSearchChange = useCallback((value: string) => { const handleSearchChange = useCallback(
setSearch(value); (value: string) => {
router.get(employeeIndex.url(), { setSearch(value);
...filters, router.get(
page: 1, employeeIndex.url(),
per_page: pagination.per_page, {
search: value, ...filters,
sort: sort.column, page: 1,
direction: sort.direction, per_page: pagination.per_page,
}, { preserveState: true, replace: true }); search: value,
}, [pagination.per_page, sort, filters]); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort, filters],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') { function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction }); setSort({ column, direction });
router.get(employeeIndex.url(), { router.get(
...filters, employeeIndex.url(),
page: 1, {
per_page: pagination.per_page, ...filters,
search, page: 1,
sort: column, per_page: pagination.per_page,
direction, search,
}, { preserveState: true, replace: true }); sort: column,
direction,
},
{ preserveState: true, replace: true },
);
} }
function handleDelete() { function handleDelete() {
@ -133,9 +181,13 @@ export default function EmployeeIndex({ employees, filters }: Props) {
return; return;
} }
router.post(resetPasswordRoute.url(resetPasswordTarget.id), {}, { router.post(
onSuccess: () => setResetPasswordTarget(null), resetPasswordRoute.url(resetPasswordTarget.id),
}); {},
{
onSuccess: () => setResetPasswordTarget(null),
},
);
} }
const columns = createEmployeeColumns({ const columns = createEmployeeColumns({
@ -183,17 +235,29 @@ export default function EmployeeIndex({ employees, filters }: Props) {
</label> </label>
<Select <Select
value={filters.employment_status ?? 'all'} value={filters.employment_status ?? 'all'}
onValueChange={(value) => applyFilter('employment_status', value)} onValueChange={(value) =>
applyFilter('employment_status', value)
}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" /> <SelectValue placeholder="Semua Status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Semua Status</SelectItem> <SelectItem value="all">
<SelectItem value="full_time">Full Time</SelectItem> Semua Status
<SelectItem value="part_time">Part Time</SelectItem> </SelectItem>
<SelectItem value="contract">Kontrak</SelectItem> <SelectItem value="full_time">
<SelectItem value="internship">Magang</SelectItem> Full Time
</SelectItem>
<SelectItem value="part_time">
Part Time
</SelectItem>
<SelectItem value="contract">
Kontrak
</SelectItem>
<SelectItem value="internship">
Magang
</SelectItem>
<SelectItem value="resigned">Keluar</SelectItem> <SelectItem value="resigned">Keluar</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@ -205,7 +269,9 @@ export default function EmployeeIndex({ employees, filters }: Props) {
</label> </label>
<Select <Select
value={filters.is_active ?? 'all'} value={filters.is_active ?? 'all'}
onValueChange={(value) => applyFilter('is_active', value)} onValueChange={(value) =>
applyFilter('is_active', value)
}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Semua" /> <SelectValue placeholder="Semua" />
@ -224,7 +290,9 @@ export default function EmployeeIndex({ employees, filters }: Props) {
</label> </label>
<Select <Select
value={filters.gender ?? 'all'} value={filters.gender ?? 'all'}
onValueChange={(value) => applyFilter('gender', value)} onValueChange={(value) =>
applyFilter('gender', value)
}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Semua" /> <SelectValue placeholder="Semua" />
@ -232,7 +300,9 @@ export default function EmployeeIndex({ employees, filters }: Props) {
<SelectContent> <SelectContent>
<SelectItem value="all">Semua</SelectItem> <SelectItem value="all">Semua</SelectItem>
<SelectItem value="male">Laki-laki</SelectItem> <SelectItem value="male">Laki-laki</SelectItem>
<SelectItem value="female">Perempuan</SelectItem> <SelectItem value="female">
Perempuan
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@ -7,7 +7,13 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { ArrowUpDown, CheckCircle, Pencil, Trash2, XCircle } from 'lucide-react'; import {
ArrowUpDown,
CheckCircle,
Pencil,
Trash2,
XCircle,
} from 'lucide-react';
export type LeaveRequest = { export type LeaveRequest = {
id: number; id: number;
@ -74,16 +80,15 @@ type CreateColumnsParams = {
export function createLeaveRequestColumns( export function createLeaveRequestColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<LeaveRequest>[] { ): ColumnDef<LeaveRequest>[] {
const { handleEdit, handleDeleteClick, handleApprove, handleReject } = params; const { handleEdit, handleDeleteClick, handleApprove, handleReject } =
params;
return [ return [
{ {
id: 'no', id: 'no',
header: () => <span className="block text-center">No</span>, header: () => <span className="block text-center">No</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block text-center"> <span className="block text-center">{row.index + 1}</span>
{row.index + 1}
</span>
), ),
meta: { meta: {
className: 'w-[50px] text-center', className: 'w-[50px] text-center',
@ -96,7 +101,11 @@ export function createLeaveRequestColumns(
cell: ({ row }) => { cell: ({ row }) => {
const employee = row.original.employee; const employee = row.original.employee;
return <span>{employee?.user?.user_profile?.full_name ?? '-'}</span>; return (
<span>
{employee?.user?.user_profile?.full_name ?? '-'}
</span>
);
}, },
}, },
{ {
@ -106,9 +115,7 @@ export function createLeaveRequestColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Tanggal Mulai</span> <span>Tanggal Mulai</span>
@ -116,7 +123,9 @@ export function createLeaveRequestColumns(
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span>{formatShortDate(row.getValue('start_date') as string)}</span> <span>
{formatShortDate(row.getValue('start_date') as string)}
</span>
), ),
}, },
{ {
@ -126,9 +135,7 @@ export function createLeaveRequestColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Tanggal Selesai</span> <span>Tanggal Selesai</span>
@ -136,7 +143,9 @@ export function createLeaveRequestColumns(
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span>{formatShortDate(row.getValue('end_date') as string)}</span> <span>
{formatShortDate(row.getValue('end_date') as string)}
</span>
), ),
}, },
{ {
@ -146,9 +155,7 @@ export function createLeaveRequestColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Hari</span> <span>Hari</span>
@ -224,16 +231,12 @@ export function createLeaveRequestColumns(
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => onClick={() => handleEdit(leaveRequest)}
handleEdit(leaveRequest)
}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">Edit</TooltipContent>
Edit
</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>

View File

@ -15,9 +15,26 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; Popover,
import { approve, destroy, index as leaveRequestIndex, reject, store, update } from '@/routes/admin/hr/leave-requests'; PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
approve,
destroy,
index as leaveRequestIndex,
reject,
store,
update,
} from '@/routes/admin/hr/leave-requests';
import type { LeaveRequest } from './columns'; import type { LeaveRequest } from './columns';
import { createLeaveRequestColumns } from './columns'; import { createLeaveRequestColumns } from './columns';
@ -42,11 +59,18 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
const [rejecting, setRejecting] = useState<LeaveRequest | null>(null); const [rejecting, setRejecting] = useState<LeaveRequest | null>(null);
const [startDate, setStartDate] = useState<Date | undefined>(undefined); const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined); const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [editingStartDate, setEditingStartDate] = useState<Date | undefined>(undefined); const [editingStartDate, setEditingStartDate] = useState<Date | undefined>(
const [editingEndDate, setEditingEndDate] = useState<Date | undefined>(undefined); undefined,
);
const [editingEndDate, setEditingEndDate] = useState<Date | undefined>(
undefined,
);
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' }); const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = { const pagination: PaginationState = {
current_page: leaveRequests.current_page, current_page: leaveRequests.current_page,
@ -76,24 +100,32 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
newFilters[key as keyof typeof newFilters] = value; newFilters[key as keyof typeof newFilters] = value;
} }
router.get(leaveRequestIndex(), { router.get(
...newFilters, leaveRequestIndex(),
page: 1, {
per_page: pagination.per_page, ...newFilters,
search, page: 1,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
function clearFilters() { function clearFilters() {
router.get(leaveRequestIndex(), { router.get(
page: 1, leaveRequestIndex(),
per_page: pagination.per_page, {
search, page: 1,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
setFilterOpen(false); setFilterOpen(false);
} }
@ -112,9 +144,13 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
return; return;
} }
router.post(approve(approving.id), {}, { router.post(
onSuccess: () => setApproving(null), approve(approving.id),
}); {},
{
onSuccess: () => setApproving(null),
},
);
} }
function handleReject() { function handleReject() {
@ -122,55 +158,78 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
return; return;
} }
router.post(reject(rejecting.id), {}, { router.post(
onSuccess: () => setRejecting(null), reject(rejecting.id),
}); {},
{
onSuccess: () => setRejecting(null),
},
);
} }
function handlePageChange(page: number) { function handlePageChange(page: number) {
router.get(leaveRequestIndex(), { router.get(
...filters, leaveRequestIndex(),
page, {
per_page: pagination.per_page, ...filters,
search, page,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
function handlePerPageChange(perPage: number) { function handlePerPageChange(perPage: number) {
router.get(leaveRequestIndex(), { router.get(
...filters, leaveRequestIndex(),
page: 1, {
per_page: perPage, ...filters,
search, page: 1,
sort: sort.column, per_page: perPage,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
const handleSearchChange = useCallback((value: string) => { const handleSearchChange = useCallback(
setSearch(value); (value: string) => {
router.get(leaveRequestIndex(), { setSearch(value);
...filters, router.get(
page: 1, leaveRequestIndex(),
per_page: pagination.per_page, {
search: value, ...filters,
sort: sort.column, page: 1,
direction: sort.direction, per_page: pagination.per_page,
}, { preserveState: true, replace: true }); search: value,
}, [pagination.per_page, sort, filters]); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort, filters],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') { function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction }); setSort({ column, direction });
router.get(leaveRequestIndex(), { router.get(
...filters, leaveRequestIndex(),
page: 1, {
per_page: pagination.per_page, ...filters,
search, page: 1,
sort: column, per_page: pagination.per_page,
direction, search,
}, { preserveState: true, replace: true }); sort: column,
direction,
},
{ preserveState: true, replace: true },
);
} }
const columns = createLeaveRequestColumns({ const columns = createLeaveRequestColumns({
@ -216,17 +275,29 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
</label> </label>
<Select <Select
value={filters.status ?? 'all'} value={filters.status ?? 'all'}
onValueChange={(value) => applyFilter('status', value)} onValueChange={(value) =>
applyFilter('status', value)
}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" /> <SelectValue placeholder="Semua Status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Semua Status</SelectItem> <SelectItem value="all">
<SelectItem value="pending">Menunggu</SelectItem> Semua Status
<SelectItem value="approved">Disetujui</SelectItem> </SelectItem>
<SelectItem value="rejected">Ditolak</SelectItem> <SelectItem value="pending">
<SelectItem value="cancelled">Dibatalkan</SelectItem> Menunggu
</SelectItem>
<SelectItem value="approved">
Disetujui
</SelectItem>
<SelectItem value="rejected">
Ditolak
</SelectItem>
<SelectItem value="cancelled">
Dibatalkan
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -246,14 +317,17 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
Cuti Cuti
</h2> </h2>
</div> </div>
<Dialog open={createOpen} onOpenChange={(open) => { <Dialog
setCreateOpen(open); open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) { if (!open) {
setStartDate(undefined); setStartDate(undefined);
setEndDate(undefined); setEndDate(undefined);
} }
}}> }}
>
<Button asChild> <Button asChild>
<button <button
type="button" type="button"
@ -264,52 +338,97 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
</button> </button>
</Button> </Button>
<DialogContent> <DialogContent>
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}> <Form
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors, processing }) => { {({ errors, processing }) => {
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Tambah Permohonan Cuti</DialogTitle> <DialogTitle>
Tambah Permohonan Cuti
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Tanggal Mulai{' '} <span className="text-destructive">*</span> Tanggal Mulai{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<input type="hidden" name="start_date" value={startDate ? startDate.toISOString().split('T')[0] : ''} /> <input
type="hidden"
name="start_date"
value={
startDate
? startDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker <DatePicker
value={startDate} value={startDate}
onChange={setStartDate} onChange={setStartDate}
placeholder="Pilih tanggal mulai" placeholder="Pilih tanggal mulai"
min={new Date()} min={new Date()}
/> />
<InputError message={errors.start_date} /> <InputError
message={
errors.start_date
}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Tanggal Selesai{' '} <span className="text-destructive">*</span> Tanggal Selesai{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<input type="hidden" name="end_date" value={endDate ? endDate.toISOString().split('T')[0] : ''} /> <input
type="hidden"
name="end_date"
value={
endDate
? endDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker <DatePicker
value={endDate} value={endDate}
onChange={setEndDate} onChange={setEndDate}
placeholder="Pilih tanggal selesai" placeholder="Pilih tanggal selesai"
min={startDate} min={startDate}
/> />
<InputError message={errors.end_date} /> <InputError
message={
errors.end_date
}
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => setCreateOpen(false)} onClick={() =>
setCreateOpen(false)
}
> >
Batal Batal
</Button> </Button>
<Button <Button
type='submit' type="submit"
disabled={processing} disabled={processing}
> >
{processing {processing
@ -353,40 +472,91 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
> >
<DialogContent> <DialogContent>
{editing && ( {editing && (
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => { <Form
setEditing(null); action={update(editing.id)}
setEditingStartDate(undefined); resetOnSuccess
setEditingEndDate(undefined); onSuccess={() => {
}}> setEditing(null);
setEditingStartDate(undefined);
setEditingEndDate(undefined);
}}
>
{({ errors, processing }) => { {({ errors, processing }) => {
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Permohonan Cuti</DialogTitle> <DialogTitle>
Edit Permohonan Cuti
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Tanggal Mulai{' '} <span className="text-destructive">*</span></Label> <Label>
<input type="hidden" name="start_date" value={editingStartDate ? editingStartDate.toISOString().split('T')[0] : ''} /> Tanggal Mulai{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="start_date"
value={
editingStartDate
? editingStartDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker <DatePicker
value={editingStartDate} value={editingStartDate}
onChange={setEditingStartDate} onChange={
setEditingStartDate
}
placeholder="Pilih tanggal mulai" placeholder="Pilih tanggal mulai"
min={new Date()} min={new Date()}
/> />
<InputError message={errors.start_date} /> <InputError
message={
errors.start_date
}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Tanggal Selesai{' '} <span className="text-destructive">*</span></Label> <Label>
<input type="hidden" name="end_date" value={editingEndDate ? editingEndDate.toISOString().split('T')[0] : ''} /> Tanggal Selesai{' '}
<span className="text-destructive">
*
</span>
</Label>
<input
type="hidden"
name="end_date"
value={
editingEndDate
? editingEndDate
.toISOString()
.split(
'T',
)[0]
: ''
}
/>
<DatePicker <DatePicker
value={editingEndDate} value={editingEndDate}
onChange={setEditingEndDate} onChange={
setEditingEndDate
}
placeholder="Pilih tanggal selesai" placeholder="Pilih tanggal selesai"
min={editingStartDate} min={editingStartDate}
/> />
<InputError message={errors.end_date} /> <InputError
message={
errors.end_date
}
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>

View File

@ -28,9 +28,7 @@ export function createCategoryColumns(
id: 'no', id: 'no',
header: () => <span className="block text-center">No</span>, header: () => <span className="block text-center">No</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block text-center"> <span className="block text-center">{row.index + 1}</span>
{row.index + 1}
</span>
), ),
meta: { meta: {
className: 'w-[50px] text-center', className: 'w-[50px] text-center',
@ -44,9 +42,7 @@ export function createCategoryColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Nama</span> <span>Nama</span>
@ -77,16 +73,12 @@ export function createCategoryColumns(
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => onClick={() => handleEdit(category)}
handleEdit(category)
}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">Edit</TooltipContent>
Edit
</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>

View File

@ -15,7 +15,12 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { destroy, index as categoryIndex, store, update } from '@/routes/admin/master/categories'; import {
destroy,
index as categoryIndex,
store,
update,
} from '@/routes/admin/master/categories';
import { createCategoryColumns } from './columns'; import { createCategoryColumns } from './columns';
import type { Category } from './columns'; import type { Category } from './columns';
@ -34,7 +39,10 @@ export default function CategoryIndex({ categories }: Props) {
const [editing, setEditing] = useState<Category | null>(null); const [editing, setEditing] = useState<Category | null>(null);
const [deleting, setDeleting] = useState<Category | null>(null); const [deleting, setDeleting] = useState<Category | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' }); const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = { const pagination: PaginationState = {
current_page: categories.current_page, current_page: categories.current_page,
@ -44,57 +52,76 @@ export default function CategoryIndex({ categories }: Props) {
}; };
function handlePageChange(page: number) { function handlePageChange(page: number) {
router.get(categoryIndex.url(), { router.get(
page, categoryIndex.url(),
per_page: pagination.per_page, {
search, page,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { sort: sort.column,
preserveState: true, direction: sort.direction,
replace: true, },
}); {
preserveState: true,
replace: true,
},
);
} }
function handlePerPageChange(perPage: number) { function handlePerPageChange(perPage: number) {
router.get(categoryIndex.url(), { router.get(
page: 1, categoryIndex.url(),
per_page: perPage, {
search, page: 1,
sort: sort.column, per_page: perPage,
direction: sort.direction, search,
}, { sort: sort.column,
preserveState: true, direction: sort.direction,
replace: true, },
}); {
preserveState: true,
replace: true,
},
);
} }
const handleSearchChange = useCallback((value: string) => { const handleSearchChange = useCallback(
setSearch(value); (value: string) => {
router.get(categoryIndex.url(), { setSearch(value);
page: 1, router.get(
per_page: pagination.per_page, categoryIndex.url(),
search: value, {
sort: sort.column, page: 1,
direction: sort.direction, per_page: pagination.per_page,
}, { search: value,
preserveState: true, sort: sort.column,
replace: true, direction: sort.direction,
}); },
}, [pagination.per_page, sort]); {
preserveState: true,
replace: true,
},
);
},
[pagination.per_page, sort],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') { function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction }); setSort({ column, direction });
router.get(categoryIndex.url(), { router.get(
page: 1, categoryIndex.url(),
per_page: pagination.per_page, {
search, page: 1,
sort: column, per_page: pagination.per_page,
direction, search,
}, { sort: column,
preserveState: true, direction,
replace: true, },
}); {
preserveState: true,
replace: true,
},
);
} }
function handleDelete() { function handleDelete() {
@ -134,37 +161,49 @@ export default function CategoryIndex({ categories }: Props) {
</button> </button>
</Button> </Button>
<DialogContent> <DialogContent>
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}> <Form
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors, processing }) => { {({ errors, processing }) => {
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Tambah Kategori</DialogTitle> <DialogTitle>
Tambah Kategori
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="name"> <Label htmlFor="name">
Nama{' '} <span className="text-destructive">*</span> Nama{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="name" id="name"
name="name" name="name"
placeholder="Masukkan nama kategori" placeholder="Masukkan nama kategori"
/> />
<InputError message={errors.name} /> <InputError
message={errors.name}
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => setCreateOpen(false)} onClick={() =>
setCreateOpen(false)
}
> >
Batal Batal
</Button> </Button>
<Button <Button
type='submit' type="submit"
disabled={processing} disabled={processing}
> >
{processing {processing
@ -205,24 +244,38 @@ export default function CategoryIndex({ categories }: Props) {
> >
<DialogContent> <DialogContent>
{editing && ( {editing && (
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}> <Form
action={update(editing.id)}
resetOnSuccess
onSuccess={() => setEditing(null)}
>
{({ errors, processing }) => { {({ errors, processing }) => {
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Kategori</DialogTitle> <DialogTitle>
Edit Kategori
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="edit-name">Nama{' '} <span className="text-destructive">*</span></Label> <Label htmlFor="edit-name">
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input <Input
id="edit-name" id="edit-name"
name="name" name="name"
placeholder="Masukkan nama kategori" placeholder="Masukkan nama kategori"
defaultValue={editing.name} defaultValue={
editing.name
}
/>
<InputError
message={errors.name}
/> />
<InputError message={errors.name} />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>

View File

@ -30,9 +30,7 @@ export function createCustomerColumns(
id: 'no', id: 'no',
header: () => <span className="block text-center">No</span>, header: () => <span className="block text-center">No</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block text-center"> <span className="block text-center">{row.index + 1}</span>
{row.index + 1}
</span>
), ),
meta: { meta: {
className: 'w-[50px] text-center', className: 'w-[50px] text-center',
@ -46,9 +44,7 @@ export function createCustomerColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Nama</span> <span>Nama</span>
@ -68,9 +64,7 @@ export function createCustomerColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>No. Telepon</span> <span>No. Telepon</span>
@ -78,9 +72,7 @@ export function createCustomerColumns(
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span> <span>{(row.getValue('phone_number') as string) ?? '-'}</span>
{row.getValue('phone_number') as string ?? '-'}
</span>
), ),
}, },
{ {
@ -90,9 +82,7 @@ export function createCustomerColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Alamat</span> <span>Alamat</span>
@ -100,8 +90,8 @@ export function createCustomerColumns(
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="max-w-[200px] truncate block"> <span className="block max-w-[200px] truncate">
{row.getValue('address') as string ?? '-'} {(row.getValue('address') as string) ?? '-'}
</span> </span>
), ),
}, },
@ -123,16 +113,12 @@ export function createCustomerColumns(
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => onClick={() => handleEdit(customer)}
handleEdit(customer)
}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">Edit</TooltipContent>
Edit
</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>

View File

@ -16,7 +16,12 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { index as customerIndex, destroy, store, update } from '@/routes/admin/master/customers'; import {
index as customerIndex,
destroy,
store,
update,
} from '@/routes/admin/master/customers';
import type { Customer } from './columns'; import type { Customer } from './columns';
import { createCustomerColumns } from './columns'; import { createCustomerColumns } from './columns';
@ -35,7 +40,10 @@ export default function CustomerIndex({ customers }: Props) {
const [editing, setEditing] = useState<Customer | null>(null); const [editing, setEditing] = useState<Customer | null>(null);
const [deleting, setDeleting] = useState<Customer | null>(null); const [deleting, setDeleting] = useState<Customer | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' }); const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = { const pagination: PaginationState = {
current_page: customers.current_page, current_page: customers.current_page,
@ -45,57 +53,76 @@ export default function CustomerIndex({ customers }: Props) {
}; };
function handlePageChange(page: number) { function handlePageChange(page: number) {
router.get(customerIndex.url(), { router.get(
page, customerIndex.url(),
per_page: pagination.per_page, {
search, page,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { sort: sort.column,
preserveState: true, direction: sort.direction,
replace: true, },
}); {
preserveState: true,
replace: true,
},
);
} }
function handlePerPageChange(perPage: number) { function handlePerPageChange(perPage: number) {
router.get(customerIndex.url(), { router.get(
page: 1, customerIndex.url(),
per_page: perPage, {
search, page: 1,
sort: sort.column, per_page: perPage,
direction: sort.direction, search,
}, { sort: sort.column,
preserveState: true, direction: sort.direction,
replace: true, },
}); {
preserveState: true,
replace: true,
},
);
} }
const handleSearchChange = useCallback((value: string) => { const handleSearchChange = useCallback(
setSearch(value); (value: string) => {
router.get(customerIndex.url(), { setSearch(value);
page: 1, router.get(
per_page: pagination.per_page, customerIndex.url(),
search: value, {
sort: sort.column, page: 1,
direction: sort.direction, per_page: pagination.per_page,
}, { search: value,
preserveState: true, sort: sort.column,
replace: true, direction: sort.direction,
}); },
}, [pagination.per_page, sort]); {
preserveState: true,
replace: true,
},
);
},
[pagination.per_page, sort],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') { function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction }); setSort({ column, direction });
router.get(customerIndex.url(), { router.get(
page: 1, customerIndex.url(),
per_page: pagination.per_page, {
search, page: 1,
sort: column, per_page: pagination.per_page,
direction, search,
}, { sort: column,
preserveState: true, direction,
replace: true, },
}); {
preserveState: true,
replace: true,
},
);
} }
function handleDelete() { function handleDelete() {
@ -135,32 +162,46 @@ export default function CustomerIndex({ customers }: Props) {
</button> </button>
</Button> </Button>
<DialogContent> <DialogContent>
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}> <Form
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors, processing }) => { {({ errors, processing }) => {
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Tambah Customer</DialogTitle> <DialogTitle>
Tambah Customer
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="name"> <Label htmlFor="name">
Nama{' '} <span className="text-destructive">*</span> Nama{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="name" id="name"
name="name" name="name"
placeholder="Masukkan nama customer" placeholder="Masukkan nama customer"
/> />
<InputError message={errors.name} /> <InputError
message={errors.name}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="phone_number"> <Label htmlFor="phone_number">
No. Telepon No. Telepon
</Label> </Label>
<PhoneNumberInput name="phone_number"/> <PhoneNumberInput name="phone_number" />
<InputError message={errors.phone_number} /> <InputError
message={
errors.phone_number
}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="address"> <Label htmlFor="address">
@ -171,19 +212,23 @@ export default function CustomerIndex({ customers }: Props) {
name="address" name="address"
placeholder="Masukkan alamat" placeholder="Masukkan alamat"
/> />
<InputError message={errors.address} /> <InputError
message={errors.address}
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => setCreateOpen(false)} onClick={() =>
setCreateOpen(false)
}
> >
Batal Batal
</Button> </Button>
<Button <Button
type='submit' type="submit"
disabled={processing} disabled={processing}
> >
{processing {processing
@ -224,27 +269,43 @@ export default function CustomerIndex({ customers }: Props) {
> >
<DialogContent> <DialogContent>
{editing && ( {editing && (
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}> <Form
action={update(editing.id)}
resetOnSuccess
onSuccess={() => setEditing(null)}
>
{({ errors, processing }) => { {({ errors, processing }) => {
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Customer</DialogTitle> <DialogTitle>
Edit Customer
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="edit-name">Nama{' '} <span className="text-destructive">*</span></Label> <Label htmlFor="edit-name">
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input <Input
id="edit-name" id="edit-name"
name="name" name="name"
placeholder="Masukkan nama customer" placeholder="Masukkan nama customer"
defaultValue={editing.name} defaultValue={
editing.name
}
/>
<InputError
message={errors.name}
/> />
<InputError message={errors.name} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="edit-phone_number">No. Telepon</Label> <Label htmlFor="edit-phone_number">
No. Telepon
</Label>
<Input <Input
id="edit-phone_number" id="edit-phone_number"
name="phone_number" name="phone_number"
@ -252,19 +313,33 @@ export default function CustomerIndex({ customers }: Props) {
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
placeholder="Masukkan nomor telepon" placeholder="Masukkan nomor telepon"
defaultValue={editing.phone_number ?? ''} defaultValue={
editing.phone_number ??
''
}
/>
<InputError
message={
errors.phone_number
}
/> />
<InputError message={errors.phone_number} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="edit-address">Alamat</Label> <Label htmlFor="edit-address">
Alamat
</Label>
<Input <Input
id="edit-address" id="edit-address"
name="address" name="address"
placeholder="Masukkan alamat" placeholder="Masukkan alamat"
defaultValue={editing.address ?? ''} defaultValue={
editing.address ??
''
}
/>
<InputError
message={errors.address}
/> />
<InputError message={errors.address} />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>

View File

@ -70,7 +70,10 @@ function formatNumber(num: number): string {
return new Intl.NumberFormat('id-ID').format(num); return new Intl.NumberFormat('id-ID').format(num);
} }
function getFilteredVariants(allVariants: ProductVariant[], searchValue: string): ProductVariant[] { function getFilteredVariants(
allVariants: ProductVariant[],
searchValue: string,
): ProductVariant[] {
const query = searchValue.toLowerCase().trim(); const query = searchValue.toLowerCase().trim();
return query return query
? allVariants.filter((v) => v.name.toLowerCase().includes(query)) ? allVariants.filter((v) => v.name.toLowerCase().includes(query))
@ -93,7 +96,8 @@ export function createProductColumns(
id: 'expand', id: 'expand',
header: '', header: '',
cell: ({ row }) => { cell: ({ row }) => {
const hasVariants = (row.original.product_variants?.length ?? 0) > 0; const hasVariants =
(row.original.product_variants?.length ?? 0) > 0;
if (!hasVariants) { if (!hasVariants) {
return null; return null;
@ -119,7 +123,8 @@ export function createProductColumns(
}, },
{ {
id: 'variant_names', id: 'variant_names',
accessorFn: (row) => row.product_variants?.map((v) => v.name).join(' ') ?? '', accessorFn: (row) =>
row.product_variants?.map((v) => v.name).join(' ') ?? '',
header: () => null, header: () => null,
cell: () => null, cell: () => null,
meta: { meta: {
@ -131,9 +136,7 @@ export function createProductColumns(
id: 'no', id: 'no',
header: () => <span className="block text-center">No</span>, header: () => <span className="block text-center">No</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block text-center"> <span className="block text-center">{row.index + 1}</span>
{row.index + 1}
</span>
), ),
meta: { meta: {
className: 'w-[50px] text-center', className: 'w-[50px] text-center',
@ -147,9 +150,7 @@ export function createProductColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Nama Produk</span> <span>Nama Produk</span>
@ -161,11 +162,11 @@ export function createProductColumns(
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium"> <span className="font-medium">{product.name}</span>
{product.name}
</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{product.categories?.map((c) => c.name).join(', ') || '-'} {product.categories
?.map((c) => c.name)
.join(', ') || '-'}
</span> </span>
</div> </div>
); );
@ -175,8 +176,14 @@ export function createProductColumns(
id: 'variants', id: 'variants',
header: () => <span>Varian</span>, header: () => <span>Varian</span>,
cell: ({ row, table }) => { cell: ({ row, table }) => {
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? ''; const searchValue =
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue); (table
.getColumn('variant_names')
?.getFilterValue() as string) ?? '';
const variants = getFilteredVariants(
row.original.product_variants ?? [],
searchValue,
);
return ( return (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium"> <span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
@ -193,9 +200,18 @@ export function createProductColumns(
headerClassName: 'w-[80px] text-center', headerClassName: 'w-[80px] text-center',
}, },
cell: ({ row, table }) => { cell: ({ row, table }) => {
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? ''; const searchValue =
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue); (table
const totalStock = variants.reduce((sum, v) => sum + (v.stock ?? 0), 0); .getColumn('variant_names')
?.getFilterValue() as string) ?? '';
const variants = getFilteredVariants(
row.original.product_variants ?? [],
searchValue,
);
const totalStock = variants.reduce(
(sum, v) => sum + (v.stock ?? 0),
0,
);
return ( return (
<span className="block text-center font-medium"> <span className="block text-center font-medium">
@ -206,15 +222,26 @@ export function createProductColumns(
}, },
{ {
id: 'reject_stock', id: 'reject_stock',
header: () => <span className="block text-center">Stok Reject</span>, header: () => (
<span className="block text-center">Stok Reject</span>
),
meta: { meta: {
className: 'w-[80px] text-center', className: 'w-[80px] text-center',
headerClassName: 'w-[80px] text-center', headerClassName: 'w-[80px] text-center',
}, },
cell: ({ row, table }) => { cell: ({ row, table }) => {
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? ''; const searchValue =
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue); (table
const totalReject = variants.reduce((sum, v) => sum + (v.reject_stock ?? 0), 0); .getColumn('variant_names')
?.getFilterValue() as string) ?? '';
const variants = getFilteredVariants(
row.original.product_variants ?? [],
searchValue,
);
const totalReject = variants.reduce(
(sum, v) => sum + (v.reject_stock ?? 0),
0,
);
return ( return (
<span className="block text-center font-medium"> <span className="block text-center font-medium">
@ -231,9 +258,18 @@ export function createProductColumns(
headerClassName: 'w-[80px] text-center', headerClassName: 'w-[80px] text-center',
}, },
cell: ({ row, table }) => { cell: ({ row, table }) => {
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? ''; const searchValue =
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue); (table
const totalRetail = variants.reduce((sum, v) => sum + (v.retail_stock ?? 0), 0); .getColumn('variant_names')
?.getFilterValue() as string) ?? '';
const variants = getFilteredVariants(
row.original.product_variants ?? [],
searchValue,
);
const totalRetail = variants.reduce(
(sum, v) => sum + (v.retail_stock ?? 0),
0,
);
return ( return (
<span className="block text-center font-medium"> <span className="block text-center font-medium">
@ -250,11 +286,26 @@ export function createProductColumns(
headerClassName: 'w-[80px] text-center', headerClassName: 'w-[80px] text-center',
}, },
cell: ({ row, table }) => { cell: ({ row, table }) => {
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? ''; const searchValue =
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue); (table
const totalStock = variants.reduce((sum, v) => sum + (v.stock ?? 0), 0); .getColumn('variant_names')
const totalReject = variants.reduce((sum, v) => sum + (v.reject_stock ?? 0), 0); ?.getFilterValue() as string) ?? '';
const totalRetail = variants.reduce((sum, v) => sum + (v.retail_stock ?? 0), 0); const variants = getFilteredVariants(
row.original.product_variants ?? [],
searchValue,
);
const totalStock = variants.reduce(
(sum, v) => sum + (v.stock ?? 0),
0,
);
const totalReject = variants.reduce(
(sum, v) => sum + (v.reject_stock ?? 0),
0,
);
const totalRetail = variants.reduce(
(sum, v) => sum + (v.retail_stock ?? 0),
0,
);
const total = totalStock + totalReject + totalRetail; const total = totalStock + totalReject + totalRetail;
return ( return (
@ -271,9 +322,7 @@ export function createProductColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Status</span> <span>Status</span>
@ -282,18 +331,26 @@ export function createProductColumns(
), ),
cell: ({ row }) => { cell: ({ row }) => {
const product = row.original; const product = row.original;
const isToggleable = product.status === 'active' || product.status === 'inactive'; const isToggleable =
product.status === 'active' ||
product.status === 'inactive';
const isChecked = product.status === 'active'; const isChecked = product.status === 'active';
function handleToggle(checked: boolean) { function handleToggle(checked: boolean) {
router.post(toggleStatusUrl(product.id), {}, { router.post(
preserveScroll: true, toggleStatusUrl(product.id),
}); {},
{
preserveScroll: true,
},
);
} }
if (!isToggleable) { if (!isToggleable) {
return ( return (
<span className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getStatusVariant(product.status)}`}> <span
className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ${getStatusVariant(product.status)}`}
>
{getStatusLabel(product.status)} {getStatusLabel(product.status)}
</span> </span>
); );
@ -306,7 +363,9 @@ export function createProductColumns(
checked={isChecked} checked={isChecked}
onCheckedChange={handleToggle} onCheckedChange={handleToggle}
/> />
<span className={`text-xs font-medium ${isChecked ? 'text-green-700' : 'text-red-700'}`}> <span
className={`text-xs font-medium ${isChecked ? 'text-green-700' : 'text-red-700'}`}
>
{getStatusLabel(product.status)} {getStatusLabel(product.status)}
</span> </span>
</div> </div>
@ -336,9 +395,7 @@ export function createProductColumns(
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">Edit</TooltipContent>
Edit
</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
@ -346,7 +403,9 @@ export function createProductColumns(
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleDeleteClick(product)} onClick={() =>
handleDeleteClick(product)
}
> >
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
</Button> </Button>

View File

@ -9,7 +9,14 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { index as productIndex, store } from '@/routes/admin/master/products'; import { index as productIndex, store } from '@/routes/admin/master/products';
import { Form, Head } from '@inertiajs/react'; import { Form, Head } from '@inertiajs/react';
import { ArrowLeft, Copy, ClipboardPaste, Check, Plus, Trash2 } from 'lucide-react'; import {
ArrowLeft,
Copy,
ClipboardPaste,
Check,
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
type Category = { type Category = {
@ -50,7 +57,8 @@ type VariantState = {
export default function ProductCreate({ categories }: Props) { export default function ProductCreate({ categories }: Props) {
const [categoryIds, setCategoryIds] = useState<number[]>([]); const [categoryIds, setCategoryIds] = useState<number[]>([]);
const [useSamePrice, setUseSamePrice] = useState(true); const [useSamePrice, setUseSamePrice] = useState(true);
const [sharedPrices, setSharedPrices] = useState<Array<{ type: string; price: number }>>(createEmptyPrices()); const [sharedPrices, setSharedPrices] =
useState<Array<{ type: string; price: number }>>(createEmptyPrices());
const [variants, setVariants] = useState<VariantState[]>([ const [variants, setVariants] = useState<VariantState[]>([
{ {
name: '', name: '',
@ -85,34 +93,43 @@ export default function ProductCreate({ categories }: Props) {
setVariants((prev) => prev.filter((_, i) => i !== index)); setVariants((prev) => prev.filter((_, i) => i !== index));
}, []); }, []);
const updateVariant = useCallback((index: number, field: keyof VariantState, value: unknown) => { const updateVariant = useCallback(
setVariants((prev) => { (index: number, field: keyof VariantState, value: unknown) => {
const updated = [...prev]; setVariants((prev) => {
(updated[index] as Record<string, unknown>)[field] = value; const updated = [...prev];
return updated; (updated[index] as Record<string, unknown>)[field] = value;
}); return updated;
}, []); });
},
[],
);
const updateVariantPrice = useCallback((variantIndex: number, priceIndex: number, value: number) => { const updateVariantPrice = useCallback(
setVariants((prev) => { (variantIndex: number, priceIndex: number, value: number) => {
const updated = [...prev]; setVariants((prev) => {
updated[variantIndex] = { const updated = [...prev];
...updated[variantIndex], updated[variantIndex] = {
prices: updated[variantIndex].prices.map((p, i) => ...updated[variantIndex],
i === priceIndex ? { ...p, price: value } : p prices: updated[variantIndex].prices.map((p, i) =>
), i === priceIndex ? { ...p, price: value } : p,
}; ),
return updated; };
}); return updated;
}, []); });
},
[],
);
const updateSharedPrice = useCallback((priceIndex: number, value: number) => { const updateSharedPrice = useCallback(
setSharedPrices((prev) => { (priceIndex: number, value: number) => {
const updated = [...prev]; setSharedPrices((prev) => {
updated[priceIndex] = { ...updated[priceIndex], price: value }; const updated = [...prev];
return updated; updated[priceIndex] = { ...updated[priceIndex], price: value };
}); return updated;
}, []); });
},
[],
);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null); const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
@ -129,10 +146,16 @@ export default function ProductCreate({ categories }: Props) {
const pastePrices = useCallback((variantIndex: number) => { const pastePrices = useCallback((variantIndex: number) => {
navigator.clipboard.readText().then((text) => { navigator.clipboard.readText().then((text) => {
try { try {
const prices = JSON.parse(text) as Array<{ type: string; price: number }>; const prices = JSON.parse(text) as Array<{
type: string;
price: number;
}>;
setVariants((prev) => { setVariants((prev) => {
const updated = [...prev]; const updated = [...prev];
updated[variantIndex] = { ...updated[variantIndex], prices }; updated[variantIndex] = {
...updated[variantIndex],
prices,
};
return updated; return updated;
}); });
} catch { } catch {
@ -145,7 +168,7 @@ export default function ProductCreate({ categories }: Props) {
setVariants((prev) => { setVariants((prev) => {
const sourcePrices = prev[variantIndex].prices; const sourcePrices = prev[variantIndex].prices;
return prev.map((v, i) => return prev.map((v, i) =>
i === variantIndex ? v : { ...v, prices: [...sourcePrices] } i === variantIndex ? v : { ...v, prices: [...sourcePrices] },
); );
}); });
}, []); }, []);
@ -154,10 +177,12 @@ export default function ProductCreate({ categories }: Props) {
return { return {
category_ids: categoryIds, category_ids: categoryIds,
use_same_price: useSamePrice, use_same_price: useSamePrice,
shared_prices: useSamePrice ? sharedPrices.map((p) => ({ shared_prices: useSamePrice
type: p.type, ? sharedPrices.map((p) => ({
price: Number(p.price), type: p.type,
})) : [], price: Number(p.price),
}))
: [],
variants: variantsRef.current.map((v) => ({ variants: variantsRef.current.map((v) => ({
name: v.name, name: v.name,
stock: Number(v.stock), stock: Number(v.stock),
@ -166,7 +191,10 @@ export default function ProductCreate({ categories }: Props) {
photo_key: v.photo, photo_key: v.photo,
prices: useSamePrice prices: useSamePrice
? [] ? []
: v.prices.map((p) => ({ type: p.type, price: Number(p.price) })), : v.prices.map((p) => ({
type: p.type,
price: Number(p.price),
})),
})), })),
}; };
} }
@ -177,7 +205,9 @@ export default function ProductCreate({ categories }: Props) {
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6"> <div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Tambah Produk</h2> <h2 className="text-2xl font-semibold tracking-tight">
Tambah Produk
</h2>
<Button asChild variant="outline"> <Button asChild variant="outline">
<a href={productIndex.url()}> <a href={productIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@ -203,7 +233,10 @@ export default function ProductCreate({ categories }: Props) {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="name"> <Label htmlFor="name">
Nama Produk <span className="text-destructive">*</span> Nama Produk{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="name" id="name"
@ -213,25 +246,65 @@ export default function ProductCreate({ categories }: Props) {
<InputError message={errors.name} /> <InputError message={errors.name} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Status <span className="text-destructive">*</span></Label> <Label>
<RadioGroup name="status" defaultValue="active" className="flex gap-4"> Status{' '}
<span className="text-destructive">
*
</span>
</Label>
<RadioGroup
name="status"
defaultValue="active"
className="flex gap-4"
>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="active" id="status-active" /> <RadioGroupItem
<Label htmlFor="status-active" className="font-normal">Aktif</Label> value="active"
id="status-active"
/>
<Label
htmlFor="status-active"
className="font-normal"
>
Aktif
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="inactive" id="status-inactive" /> <RadioGroupItem
<Label htmlFor="status-inactive" className="font-normal">Non Aktif</Label> value="inactive"
id="status-inactive"
/>
<Label
htmlFor="status-inactive"
className="font-normal"
>
Non Aktif
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="draft" id="status-draft" /> <RadioGroupItem
<Label htmlFor="status-draft" className="font-normal">Draft</Label> value="draft"
id="status-draft"
/>
<Label
htmlFor="status-draft"
className="font-normal"
>
Draft
</Label>
</div> </div>
</RadioGroup> </RadioGroup>
<InputError message={errors.status} /> <InputError
message={errors.status}
/>
</div> </div>
<div className="grid gap-2 md:col-span-2"> <div className="grid gap-2 md:col-span-2">
<Label>Kategori <span className="text-destructive">*</span></Label> <Label>
Kategori{' '}
<span className="text-destructive">
*
</span>
</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{categories.map((category) => ( {categories.map((category) => (
<label <label
@ -242,24 +315,43 @@ export default function ProductCreate({ categories }: Props) {
type="checkbox" type="checkbox"
name="category_ids[]" name="category_ids[]"
value={category.id} value={category.id}
checked={categoryIds.includes(category.id)} checked={categoryIds.includes(
category.id,
)}
onChange={(e) => { onChange={(e) => {
setCategoryIds((prev) => setCategoryIds(
e.target.checked (prev) =>
? [...prev, category.id] e.target
: prev.filter((id) => id !== category.id) .checked
? [
...prev,
category.id,
]
: prev.filter(
(
id,
) =>
id !==
category.id,
),
); );
}} }}
className="rounded" className="rounded"
/> />
<span>{category.name}</span> <span>
{category.name}
</span>
</label> </label>
))} ))}
</div> </div>
<InputError message={errors.category_ids} /> <InputError
message={errors.category_ids}
/>
</div> </div>
<div className="grid gap-2 md:col-span-2"> <div className="grid gap-2 md:col-span-2">
<Label htmlFor="description">Deskripsi</Label> <Label htmlFor="description">
Deskripsi
</Label>
<Textarea <Textarea
id="description" id="description"
name="description" name="description"
@ -278,31 +370,80 @@ export default function ProductCreate({ categories }: Props) {
<RadioGroup <RadioGroup
name="use_same_price" name="use_same_price"
value={useSamePrice ? '1' : '0'} value={useSamePrice ? '1' : '0'}
onValueChange={(val) => setUseSamePrice(val === '1')} onValueChange={(val) =>
setUseSamePrice(val === '1')
}
className="flex gap-6" className="flex gap-6"
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="1" id="price-same" /> <RadioGroupItem
<Label htmlFor="price-same" className="font-normal">Semua varian sama</Label> value="1"
id="price-same"
/>
<Label
htmlFor="price-same"
className="font-normal"
>
Semua varian sama
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="0" id="price-different" /> <RadioGroupItem
<Label htmlFor="price-different" className="font-normal">Harga per varian</Label> value="0"
id="price-different"
/>
<Label
htmlFor="price-different"
className="font-normal"
>
Harga per varian
</Label>
</div> </div>
</RadioGroup> </RadioGroup>
{useSamePrice && ( {useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3"> <div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{PRICE_TYPES.map((priceType, priceIndex) => ( {PRICE_TYPES.map(
<div key={priceType.key} className="grid gap-2"> (priceType, priceIndex) => (
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label> <div
<RupiahInput key={priceType.key}
value={sharedPrices[priceIndex]?.price ?? 0} className="grid gap-2"
onValueChange={(val) => updateSharedPrice(priceIndex, val)} >
/> <Label>
<InputError message={errors[`shared_prices.${priceIndex}.price`]} /> Harga{' '}
</div> {
))} priceType.label
}{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
value={
sharedPrices[
priceIndex
]?.price ??
0
}
onValueChange={(
val,
) =>
updateSharedPrice(
priceIndex,
val,
)
}
/>
<InputError
message={
errors[
`shared_prices.${priceIndex}.price`
]
}
/>
</div>
),
)}
</div> </div>
)} )}
</CardContent> </CardContent>
@ -313,125 +454,311 @@ export default function ProductCreate({ categories }: Props) {
<CardTitle>Varian Produk</CardTitle> <CardTitle>Varian Produk</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{variants.map((variant, variantIndex) => ( {variants.map(
<div key={variantIndex} className="rounded-lg border p-4 space-y-4"> (variant, variantIndex) => (
<div className="flex items-center justify-between"> <div
<h4 className="font-medium">Varian {variantIndex + 1}</h4> key={variantIndex}
<div className="flex items-center gap-1"> className="space-y-4 rounded-lg border p-4"
{!useSamePrice && ( >
<> <div className="flex items-center justify-between">
<h4 className="font-medium">
Varian{' '}
{variantIndex + 1}
</h4>
<div className="flex items-center gap-1">
{!useSamePrice && (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
copyPrices(
variantIndex,
)
}
>
{copiedIndex ===
variantIndex ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
pastePrices(
variantIndex,
)
}
>
<ClipboardPaste className="h-4 w-4" />
Tempel
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
applyToAll(
variantIndex,
)
}
>
Terapkan
ke Semua
</Button>
</>
)}
{variantIndex >
0 && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="icon"
onClick={() => copyPrices(variantIndex)} onClick={() =>
removeVariant(
variantIndex,
)
}
> >
{copiedIndex === variantIndex ? ( <Trash2 className="h-4 w-4 text-destructive" />
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin
</Button> </Button>
<Button )}
type="button" </div>
variant="outline"
size="sm"
onClick={() => pastePrices(variantIndex)}
>
<ClipboardPaste className="h-4 w-4" />
Tempel
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => applyToAll(variantIndex)}
>
Terapkan ke Semua
</Button>
</>
)}
{variantIndex > 0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removeVariant(variantIndex)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div> </div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="grid gap-2">
<Label>
Nama Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
value={
variant.name
}
onChange={(e) =>
updateVariant(
variantIndex,
'name',
e.target
.value,
)
}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError
message={
errors[
`variants.${variantIndex}.name`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Bagus{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="number"
min={0}
value={
variant.stock
}
onChange={(e) =>
updateVariant(
variantIndex,
'stock',
Number(
e
.target
.value,
),
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.stock`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Reject{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="number"
min={0}
value={
variant.reject_stock
}
onChange={(e) =>
updateVariant(
variantIndex,
'reject_stock',
Number(
e
.target
.value,
),
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.reject_stock`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Ecer{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="number"
min={0}
value={
variant.retail_stock
}
onChange={(e) =>
updateVariant(
variantIndex,
'retail_stock',
Number(
e
.target
.value,
),
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.retail_stock`
]
}
/>
</div>
</div>
<div className="grid gap-2">
<Label>
Foto Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<FileUpload
value={
variant.photo
}
onChange={(photo) =>
updateVariant(
variantIndex,
'photo',
photo,
)
}
folder="product-variant"
onUploadingChange={(
uploading,
) =>
updateVariant(
variantIndex,
'uploading',
uploading,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.photo_key`
]
}
/>
</div>
{!useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{PRICE_TYPES.map(
(
priceType,
priceIndex,
) => (
<div
key={
priceType.key
}
className="grid gap-2"
>
<Label>
Harga{' '}
{
priceType.label
}{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
value={
variant
.prices[
priceIndex
]
?.price ??
0
}
onValueChange={(
val,
) =>
updateVariantPrice(
variantIndex,
priceIndex,
val,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.prices.${priceIndex}.price`
]
}
/>
</div>
),
)}
</div>
)}
</div> </div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4"> ),
<div className="grid gap-2"> )}
<Label>Nama Varian <span className="text-destructive">*</span></Label> <Button
<Input type="button"
value={variant.name} variant="outline"
onChange={(e) => updateVariant(variantIndex, 'name', e.target.value)} onClick={addVariant}
placeholder="Contoh: Ukuran L, Warna Merah" >
/>
<InputError message={errors[`variants.${variantIndex}.name`]} />
</div>
<div className="grid gap-2">
<Label>Stok Bagus <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.stock}
onChange={(e) => updateVariant(variantIndex, 'stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.stock`]} />
</div>
<div className="grid gap-2">
<Label>Stok Reject <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.reject_stock}
onChange={(e) => updateVariant(variantIndex, 'reject_stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.reject_stock`]} />
</div>
<div className="grid gap-2">
<Label>Stok Ecer <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.retail_stock}
onChange={(e) => updateVariant(variantIndex, 'retail_stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.retail_stock`]} />
</div>
</div>
<div className="grid gap-2">
<Label>Foto Varian <span className="text-destructive">*</span></Label>
<FileUpload
value={variant.photo}
onChange={(photo) => updateVariant(variantIndex, 'photo', photo)}
folder="product-variant"
onUploadingChange={(uploading) => updateVariant(variantIndex, 'uploading', uploading)}
/>
<InputError message={errors[`variants.${variantIndex}.photo_key`]} />
</div>
{!useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{PRICE_TYPES.map((priceType, priceIndex) => (
<div key={priceType.key} className="grid gap-2">
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label>
<RupiahInput
value={variant.prices[priceIndex]?.price ?? 0}
onValueChange={(val) => updateVariantPrice(variantIndex, priceIndex, val)}
/>
<InputError message={errors[`variants.${variantIndex}.prices.${priceIndex}.price`]} />
</div>
))}
</div>
)}
</div>
))}
<Button type="button" variant="outline" onClick={addVariant}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Tambah Varian Tambah Varian
</Button> </Button>
@ -439,13 +766,19 @@ export default function ProductCreate({ categories }: Props) {
</Card> </Card>
</div> </div>
<div className="flex items-center gap-4 mt-6"> <div className="mt-6 flex items-center gap-4">
<Button type="submit" disabled={processing || variants.some((v) => v.uploading)}> <Button
type="submit"
disabled={
processing ||
variants.some((v) => v.uploading)
}
>
{processing {processing
? 'Menyimpan...' ? 'Menyimpan...'
: variants.some((v) => v.uploading) : variants.some((v) => v.uploading)
? 'Mengunggah...' ? 'Mengunggah...'
: 'Simpan'} : 'Simpan'}
</Button> </Button>
</div> </div>
</> </>

View File

@ -9,7 +9,14 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { index as productIndex, update } from '@/routes/admin/master/products'; import { index as productIndex, update } from '@/routes/admin/master/products';
import { Form, Head } from '@inertiajs/react'; import { Form, Head } from '@inertiajs/react';
import { ArrowLeft, Copy, ClipboardPaste, Check, Plus, Trash2 } from 'lucide-react'; import {
ArrowLeft,
Copy,
ClipboardPaste,
Check,
Plus,
Trash2,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
type Category = { type Category = {
@ -56,9 +63,14 @@ function createEmptyPrices(): Array<{ type: string; price: number }> {
return PRICE_TYPES.map((pt) => ({ type: pt.key, price: 0 })); return PRICE_TYPES.map((pt) => ({ type: pt.key, price: 0 }));
} }
function arePricesEqual(a: Array<{ type: string; price: number }>, b: Array<{ type: string; price: number }>): boolean { function arePricesEqual(
a: Array<{ type: string; price: number }>,
b: Array<{ type: string; price: number }>,
): boolean {
if (a.length !== b.length) return false; if (a.length !== b.length) return false;
return a.every((pa, i) => pa.type === b[i]?.type && pa.price === b[i]?.price); return a.every(
(pa, i) => pa.type === b[i]?.type && pa.price === b[i]?.price,
);
} }
type VariantState = { type VariantState = {
@ -74,41 +86,54 @@ type VariantState = {
}; };
export default function ProductEdit({ product, categories }: Props) { export default function ProductEdit({ product, categories }: Props) {
const initialVariants: VariantState[] = product.product_variants.map((v) => ({ const initialVariants: VariantState[] = product.product_variants.map(
id: v.id, (v) => ({
name: v.name, id: v.id,
stock: v.stock, name: v.name,
reject_stock: v.reject_stock, stock: v.stock,
retail_stock: v.retail_stock, reject_stock: v.reject_stock,
photo: v.photo_key, retail_stock: v.retail_stock,
photoUrl: v.photo_url, photo: v.photo_key,
uploading: false, photoUrl: v.photo_url,
prices: v.prices.length > 0 ? v.prices : createEmptyPrices(), uploading: false,
})); prices: v.prices.length > 0 ? v.prices : createEmptyPrices(),
}),
);
const allSamePrice = initialVariants.length > 1 const allSamePrice =
? initialVariants.every((v) => arePricesEqual(v.prices, initialVariants[0].prices)) initialVariants.length > 1
: true; ? initialVariants.every((v) =>
arePricesEqual(v.prices, initialVariants[0].prices),
)
: true;
const [categoryIds, setCategoryIds] = useState<number[]>(product.category_ids); const [categoryIds, setCategoryIds] = useState<number[]>(
product.category_ids,
);
const [useSamePrice, setUseSamePrice] = useState(allSamePrice); const [useSamePrice, setUseSamePrice] = useState(allSamePrice);
const [sharedPrices, setSharedPrices] = useState<Array<{ type: string; price: number }>>( const [sharedPrices, setSharedPrices] = useState<
initialVariants.length > 0 ? initialVariants[0].prices : createEmptyPrices() Array<{ type: string; price: number }>
>(
initialVariants.length > 0
? initialVariants[0].prices
: createEmptyPrices(),
); );
const [variants, setVariants] = useState<VariantState[]>( const [variants, setVariants] = useState<VariantState[]>(
initialVariants.length > 0 ? initialVariants : [ initialVariants.length > 0
{ ? initialVariants
id: null, : [
name: '', {
stock: 0, id: null,
reject_stock: 0, name: '',
retail_stock: 0, stock: 0,
photo: null, reject_stock: 0,
photoUrl: null, retail_stock: 0,
uploading: false, photo: null,
prices: createEmptyPrices(), photoUrl: null,
}, uploading: false,
] prices: createEmptyPrices(),
},
],
); );
const variantsRef = useRef(variants); const variantsRef = useRef(variants);
@ -135,34 +160,43 @@ export default function ProductEdit({ product, categories }: Props) {
setVariants((prev) => prev.filter((_, i) => i !== index)); setVariants((prev) => prev.filter((_, i) => i !== index));
}, []); }, []);
const updateVariant = useCallback((index: number, field: keyof VariantState, value: unknown) => { const updateVariant = useCallback(
setVariants((prev) => { (index: number, field: keyof VariantState, value: unknown) => {
const updated = [...prev]; setVariants((prev) => {
(updated[index] as Record<string, unknown>)[field] = value; const updated = [...prev];
return updated; (updated[index] as Record<string, unknown>)[field] = value;
}); return updated;
}, []); });
},
[],
);
const updateVariantPrice = useCallback((variantIndex: number, priceIndex: number, value: number) => { const updateVariantPrice = useCallback(
setVariants((prev) => { (variantIndex: number, priceIndex: number, value: number) => {
const updated = [...prev]; setVariants((prev) => {
updated[variantIndex] = { const updated = [...prev];
...updated[variantIndex], updated[variantIndex] = {
prices: updated[variantIndex].prices.map((p, i) => ...updated[variantIndex],
i === priceIndex ? { ...p, price: value } : p prices: updated[variantIndex].prices.map((p, i) =>
), i === priceIndex ? { ...p, price: value } : p,
}; ),
return updated; };
}); return updated;
}, []); });
},
[],
);
const updateSharedPrice = useCallback((priceIndex: number, value: number) => { const updateSharedPrice = useCallback(
setSharedPrices((prev) => { (priceIndex: number, value: number) => {
const updated = [...prev]; setSharedPrices((prev) => {
updated[priceIndex] = { ...updated[priceIndex], price: value }; const updated = [...prev];
return updated; updated[priceIndex] = { ...updated[priceIndex], price: value };
}); return updated;
}, []); });
},
[],
);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null); const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
@ -179,10 +213,16 @@ export default function ProductEdit({ product, categories }: Props) {
const pastePrices = useCallback((variantIndex: number) => { const pastePrices = useCallback((variantIndex: number) => {
navigator.clipboard.readText().then((text) => { navigator.clipboard.readText().then((text) => {
try { try {
const prices = JSON.parse(text) as Array<{ type: string; price: number }>; const prices = JSON.parse(text) as Array<{
type: string;
price: number;
}>;
setVariants((prev) => { setVariants((prev) => {
const updated = [...prev]; const updated = [...prev];
updated[variantIndex] = { ...updated[variantIndex], prices }; updated[variantIndex] = {
...updated[variantIndex],
prices,
};
return updated; return updated;
}); });
} catch { } catch {
@ -195,7 +235,7 @@ export default function ProductEdit({ product, categories }: Props) {
setVariants((prev) => { setVariants((prev) => {
const sourcePrices = prev[variantIndex].prices; const sourcePrices = prev[variantIndex].prices;
return prev.map((v, i) => return prev.map((v, i) =>
i === variantIndex ? v : { ...v, prices: [...sourcePrices] } i === variantIndex ? v : { ...v, prices: [...sourcePrices] },
); );
}); });
}, []); }, []);
@ -205,7 +245,10 @@ export default function ProductEdit({ product, categories }: Props) {
category_ids: categoryIds, category_ids: categoryIds,
use_same_price: useSamePrice, use_same_price: useSamePrice,
shared_prices: useSamePrice shared_prices: useSamePrice
? sharedPrices.map((p) => ({ type: p.type, price: Number(p.price) })) ? sharedPrices.map((p) => ({
type: p.type,
price: Number(p.price),
}))
: [], : [],
variants: variantsRef.current.map((v) => ({ variants: variantsRef.current.map((v) => ({
id: v.id, id: v.id,
@ -216,7 +259,10 @@ export default function ProductEdit({ product, categories }: Props) {
photo_key: v.photo, photo_key: v.photo,
prices: useSamePrice prices: useSamePrice
? [] ? []
: v.prices.map((p) => ({ type: p.type, price: Number(p.price) })), : v.prices.map((p) => ({
type: p.type,
price: Number(p.price),
})),
})), })),
}; };
} }
@ -227,7 +273,9 @@ export default function ProductEdit({ product, categories }: Props) {
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6"> <div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">Edit Produk</h2> <h2 className="text-2xl font-semibold tracking-tight">
Edit Produk
</h2>
<Button asChild variant="outline"> <Button asChild variant="outline">
<a href={productIndex.url()}> <a href={productIndex.url()}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@ -254,7 +302,10 @@ export default function ProductEdit({ product, categories }: Props) {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="name"> <Label htmlFor="name">
Nama Produk <span className="text-destructive">*</span> Nama Produk{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="name" id="name"
@ -265,25 +316,65 @@ export default function ProductEdit({ product, categories }: Props) {
<InputError message={errors.name} /> <InputError message={errors.name} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Status <span className="text-destructive">*</span></Label> <Label>
<RadioGroup name="status" defaultValue={product.status} className="flex gap-4"> Status{' '}
<span className="text-destructive">
*
</span>
</Label>
<RadioGroup
name="status"
defaultValue={product.status}
className="flex gap-4"
>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="active" id="status-active" /> <RadioGroupItem
<Label htmlFor="status-active" className="font-normal">Aktif</Label> value="active"
id="status-active"
/>
<Label
htmlFor="status-active"
className="font-normal"
>
Aktif
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="inactive" id="status-inactive" /> <RadioGroupItem
<Label htmlFor="status-inactive" className="font-normal">Non Aktif</Label> value="inactive"
id="status-inactive"
/>
<Label
htmlFor="status-inactive"
className="font-normal"
>
Non Aktif
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="draft" id="status-draft" /> <RadioGroupItem
<Label htmlFor="status-draft" className="font-normal">Draft</Label> value="draft"
id="status-draft"
/>
<Label
htmlFor="status-draft"
className="font-normal"
>
Draft
</Label>
</div> </div>
</RadioGroup> </RadioGroup>
<InputError message={errors.status} /> <InputError
message={errors.status}
/>
</div> </div>
<div className="grid gap-2 md:col-span-2"> <div className="grid gap-2 md:col-span-2">
<Label>Kategori <span className="text-destructive">*</span></Label> <Label>
Kategori{' '}
<span className="text-destructive">
*
</span>
</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{categories.map((category) => ( {categories.map((category) => (
<label <label
@ -294,28 +385,49 @@ export default function ProductEdit({ product, categories }: Props) {
type="checkbox" type="checkbox"
name="category_ids[]" name="category_ids[]"
value={category.id} value={category.id}
checked={categoryIds.includes(category.id)} checked={categoryIds.includes(
category.id,
)}
onChange={(e) => { onChange={(e) => {
setCategoryIds((prev) => setCategoryIds(
e.target.checked (prev) =>
? [...prev, category.id] e.target
: prev.filter((id) => id !== category.id) .checked
? [
...prev,
category.id,
]
: prev.filter(
(
id,
) =>
id !==
category.id,
),
); );
}} }}
className="rounded" className="rounded"
/> />
<span>{category.name}</span> <span>
{category.name}
</span>
</label> </label>
))} ))}
</div> </div>
<InputError message={errors.category_ids} /> <InputError
message={errors.category_ids}
/>
</div> </div>
<div className="grid gap-2 md:col-span-2"> <div className="grid gap-2 md:col-span-2">
<Label htmlFor="description">Deskripsi</Label> <Label htmlFor="description">
Deskripsi
</Label>
<Textarea <Textarea
id="description" id="description"
name="description" name="description"
defaultValue={product.description ?? ''} defaultValue={
product.description ?? ''
}
placeholder="Masukkan deskripsi produk" placeholder="Masukkan deskripsi produk"
rows={3} rows={3}
/> />
@ -331,31 +443,80 @@ export default function ProductEdit({ product, categories }: Props) {
<RadioGroup <RadioGroup
name="use_same_price" name="use_same_price"
value={useSamePrice ? '1' : '0'} value={useSamePrice ? '1' : '0'}
onValueChange={(val) => setUseSamePrice(val === '1')} onValueChange={(val) =>
setUseSamePrice(val === '1')
}
className="flex gap-6" className="flex gap-6"
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="1" id="price-same" /> <RadioGroupItem
<Label htmlFor="price-same" className="font-normal">Semua varian sama</Label> value="1"
id="price-same"
/>
<Label
htmlFor="price-same"
className="font-normal"
>
Semua varian sama
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="0" id="price-different" /> <RadioGroupItem
<Label htmlFor="price-different" className="font-normal">Harga per varian</Label> value="0"
id="price-different"
/>
<Label
htmlFor="price-different"
className="font-normal"
>
Harga per varian
</Label>
</div> </div>
</RadioGroup> </RadioGroup>
{useSamePrice && ( {useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4"> <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{PRICE_TYPES.map((priceType, priceIndex) => ( {PRICE_TYPES.map(
<div key={priceType.key} className="grid gap-2"> (priceType, priceIndex) => (
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label> <div
<RupiahInput key={priceType.key}
value={sharedPrices[priceIndex]?.price ?? 0} className="grid gap-2"
onValueChange={(val) => updateSharedPrice(priceIndex, val)} >
/> <Label>
<InputError message={errors[`shared_prices.${priceIndex}.price`]} /> Harga{' '}
</div> {
))} priceType.label
}{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
value={
sharedPrices[
priceIndex
]?.price ??
0
}
onValueChange={(
val,
) =>
updateSharedPrice(
priceIndex,
val,
)
}
/>
<InputError
message={
errors[
`shared_prices.${priceIndex}.price`
]
}
/>
</div>
),
)}
</div> </div>
)} )}
</CardContent> </CardContent>
@ -366,126 +527,314 @@ export default function ProductEdit({ product, categories }: Props) {
<CardTitle>Varian Produk</CardTitle> <CardTitle>Varian Produk</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{variants.map((variant, variantIndex) => ( {variants.map(
<div key={variantIndex} className="rounded-lg border p-4 space-y-4"> (variant, variantIndex) => (
<div className="flex items-center justify-between"> <div
<h4 className="font-medium">Varian {variantIndex + 1}</h4> key={variantIndex}
<div className="flex items-center gap-1"> className="space-y-4 rounded-lg border p-4"
{!useSamePrice && ( >
<> <div className="flex items-center justify-between">
<h4 className="font-medium">
Varian{' '}
{variantIndex + 1}
</h4>
<div className="flex items-center gap-1">
{!useSamePrice && (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
copyPrices(
variantIndex,
)
}
>
{copiedIndex ===
variantIndex ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
pastePrices(
variantIndex,
)
}
>
<ClipboardPaste className="h-4 w-4" />
Tempel
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
applyToAll(
variantIndex,
)
}
>
Terapkan
ke Semua
</Button>
</>
)}
{variantIndex >
0 && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="icon"
onClick={() => copyPrices(variantIndex)} onClick={() =>
removeVariant(
variantIndex,
)
}
> >
{copiedIndex === variantIndex ? ( <Trash2 className="h-4 w-4 text-destructive" />
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
Salin
</Button> </Button>
<Button )}
type="button" </div>
variant="outline"
size="sm"
onClick={() => pastePrices(variantIndex)}
>
<ClipboardPaste className="h-4 w-4" />
Tempel
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => applyToAll(variantIndex)}
>
Terapkan ke Semua
</Button>
</>
)}
{variantIndex > 0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removeVariant(variantIndex)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div> </div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="grid gap-2">
<Label>
Nama Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
value={
variant.name
}
onChange={(e) =>
updateVariant(
variantIndex,
'name',
e.target
.value,
)
}
placeholder="Contoh: Ukuran L, Warna Merah"
/>
<InputError
message={
errors[
`variants.${variantIndex}.name`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Bagus{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="number"
min={0}
value={
variant.stock
}
onChange={(e) =>
updateVariant(
variantIndex,
'stock',
Number(
e
.target
.value,
),
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.stock`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Reject{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="number"
min={0}
value={
variant.reject_stock
}
onChange={(e) =>
updateVariant(
variantIndex,
'reject_stock',
Number(
e
.target
.value,
),
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.reject_stock`
]
}
/>
</div>
<div className="grid gap-2">
<Label>
Stok Ecer{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input
type="number"
min={0}
value={
variant.retail_stock
}
onChange={(e) =>
updateVariant(
variantIndex,
'retail_stock',
Number(
e
.target
.value,
),
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.retail_stock`
]
}
/>
</div>
</div>
<div className="grid gap-2">
<Label>
Foto Varian{' '}
<span className="text-destructive">
*
</span>
</Label>
<FileUpload
value={
variant.photo
}
onChange={(photo) =>
updateVariant(
variantIndex,
'photo',
photo,
)
}
folder="product-variant"
existingUrl={
variant.photoUrl
}
onUploadingChange={(
uploading,
) =>
updateVariant(
variantIndex,
'uploading',
uploading,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.photo_key`
]
}
/>
</div>
{!useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{PRICE_TYPES.map(
(
priceType,
priceIndex,
) => (
<div
key={
priceType.key
}
className="grid gap-2"
>
<Label>
Harga{' '}
{
priceType.label
}{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
value={
variant
.prices[
priceIndex
]
?.price ??
0
}
onValueChange={(
val,
) =>
updateVariantPrice(
variantIndex,
priceIndex,
val,
)
}
/>
<InputError
message={
errors[
`variants.${variantIndex}.prices.${priceIndex}.price`
]
}
/>
</div>
),
)}
</div>
)}
</div> </div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4"> ),
<div className="grid gap-2"> )}
<Label>Nama Varian <span className="text-destructive">*</span></Label> <Button
<Input type="button"
value={variant.name} variant="outline"
onChange={(e) => updateVariant(variantIndex, 'name', e.target.value)} onClick={addVariant}
placeholder="Contoh: Ukuran L, Warna Merah" >
/>
<InputError message={errors[`variants.${variantIndex}.name`]} />
</div>
<div className="grid gap-2">
<Label>Stok Bagus <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.stock}
onChange={(e) => updateVariant(variantIndex, 'stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.stock`]} />
</div>
<div className="grid gap-2">
<Label>Stok Reject <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.reject_stock}
onChange={(e) => updateVariant(variantIndex, 'reject_stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.reject_stock`]} />
</div>
<div className="grid gap-2">
<Label>Stok Ecer <span className="text-destructive">*</span></Label>
<Input
type="number"
min={0}
value={variant.retail_stock}
onChange={(e) => updateVariant(variantIndex, 'retail_stock', Number(e.target.value))}
/>
<InputError message={errors[`variants.${variantIndex}.retail_stock`]} />
</div>
</div>
<div className="grid gap-2">
<Label>Foto Varian <span className="text-destructive">*</span></Label>
<FileUpload
value={variant.photo}
onChange={(photo) => updateVariant(variantIndex, 'photo', photo)}
folder="product-variant"
existingUrl={variant.photoUrl}
onUploadingChange={(uploading) => updateVariant(variantIndex, 'uploading', uploading)}
/>
<InputError message={errors[`variants.${variantIndex}.photo_key`]} />
</div>
{!useSamePrice && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{PRICE_TYPES.map((priceType, priceIndex) => (
<div key={priceType.key} className="grid gap-2">
<Label>Harga {priceType.label} <span className="text-destructive">*</span></Label>
<RupiahInput
value={variant.prices[priceIndex]?.price ?? 0}
onValueChange={(val) => updateVariantPrice(variantIndex, priceIndex, val)}
/>
<InputError message={errors[`variants.${variantIndex}.prices.${priceIndex}.price`]} />
</div>
))}
</div>
)}
</div>
))}
<Button type="button" variant="outline" onClick={addVariant}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Tambah Varian Tambah Varian
</Button> </Button>
@ -493,13 +842,19 @@ export default function ProductEdit({ product, categories }: Props) {
</Card> </Card>
</div> </div>
<div className="flex items-center gap-4 mt-6"> <div className="mt-6 flex items-center gap-4">
<Button type="submit" disabled={processing || variants.some((v) => v.uploading)}> <Button
type="submit"
disabled={
processing ||
variants.some((v) => v.uploading)
}
>
{processing {processing
? 'Menyimpan...' ? 'Menyimpan...'
: variants.some((v) => v.uploading) : variants.some((v) => v.uploading)
? 'Mengunggah...' ? 'Mengunggah...'
: 'Simpan'} : 'Simpan'}
</Button> </Button>
</div> </div>
</> </>

View File

@ -7,9 +7,26 @@ import type { PaginationState, SortState } from '@/components/data-table';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { ImagePreviewModal } from '@/components/image-preview-modal'; import { ImagePreviewModal } from '@/components/image-preview-modal';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from '@/components/ui/combobox'; import {
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; Combobox,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { import {
Table, Table,
TableBody, TableBody,
@ -18,7 +35,13 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { destroy, create as productCreate, index as productIndex, edit as productEdit, toggleStatus } from '@/routes/admin/master/products'; import {
destroy,
create as productCreate,
index as productIndex,
edit as productEdit,
toggleStatus,
} from '@/routes/admin/master/products';
import type { Product } from './columns'; import type { Product } from './columns';
import { createProductColumns } from './columns'; import { createProductColumns } from './columns';
@ -73,7 +96,13 @@ function VariantPhotoPreview({ url, title }: { url: string; title: string }) {
); );
} }
function VariantSubRow({ row, searchValue }: { row: Row<Product>; searchValue?: string }) { function VariantSubRow({
row,
searchValue,
}: {
row: Row<Product>;
searchValue?: string;
}) {
const allVariants = row.original.product_variants ?? []; const allVariants = row.original.product_variants ?? [];
const query = (searchValue ?? '').toLowerCase().trim(); const query = (searchValue ?? '').toLowerCase().trim();
const variants = query const variants = query
@ -95,7 +124,10 @@ function VariantSubRow({ row, searchValue }: { row: Row<Product>; searchValue?:
<TableBody> <TableBody>
{variants.length === 0 ? ( {variants.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground"> <TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
Tidak ada varian. Tidak ada varian.
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -114,21 +146,36 @@ function VariantSubRow({ row, searchValue }: { row: Row<Product>; searchValue?:
</div> </div>
)} )}
</TableCell> </TableCell>
<TableCell className="font-medium">{variant.name}</TableCell> <TableCell className="font-medium">
<TableCell className="text-center">{formatNumber(variant.stock)}</TableCell> {variant.name}
<TableCell className="text-center">{formatNumber(variant.reject_stock)}</TableCell> </TableCell>
<TableCell className="text-center">{formatNumber(variant.retail_stock)}</TableCell> <TableCell className="text-center">
{formatNumber(variant.stock)}
</TableCell>
<TableCell className="text-center">
{formatNumber(variant.reject_stock)}
</TableCell>
<TableCell className="text-center">
{formatNumber(variant.retail_stock)}
</TableCell>
<TableCell> <TableCell>
{variant.product_prices?.length > 0 ? ( {variant.product_prices?.length > 0 ? (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{variant.product_prices.map((p) => ( {variant.product_prices.map((p) => (
<span key={p.id} className="text-xs"> <span
<span className="text-muted-foreground">{p.type_label}:</span>{' '} key={p.id}
className="text-xs"
>
<span className="text-muted-foreground">
{p.type_label}:
</span>{' '}
{formatCurrency(p.price)} {formatCurrency(p.price)}
</span> </span>
))} ))}
</div> </div>
) : '-'} ) : (
'-'
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@ -142,7 +189,10 @@ export default function ProductIndex({ products, filters }: Props) {
const [deleting, setDeleting] = useState<Product | null>(null); const [deleting, setDeleting] = useState<Product | null>(null);
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' }); const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const hasActiveFilters = filters.status || filters.name; const hasActiveFilters = filters.status || filters.name;
@ -175,57 +225,80 @@ export default function ProductIndex({ products, filters }: Props) {
} }
function clearFilters() { function clearFilters() {
router.get(productIndex(), {}, { router.get(
preserveState: true, productIndex(),
replace: true, {},
}); {
preserveState: true,
replace: true,
},
);
setFilterOpen(false); setFilterOpen(false);
} }
function handlePageChange(page: number) { function handlePageChange(page: number) {
router.get(productIndex.url(), { router.get(
page, productIndex.url(),
per_page: pagination.per_page, {
search, page,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
...filters, sort: sort.column,
}, { preserveState: true, replace: true }); direction: sort.direction,
...filters,
},
{ preserveState: true, replace: true },
);
} }
function handlePerPageChange(perPage: number) { function handlePerPageChange(perPage: number) {
router.get(productIndex.url(), { router.get(
page: 1, productIndex.url(),
per_page: perPage, {
search, page: 1,
sort: sort.column, per_page: perPage,
direction: sort.direction, search,
...filters, sort: sort.column,
}, { preserveState: true, replace: true }); direction: sort.direction,
...filters,
},
{ preserveState: true, replace: true },
);
} }
const handleSearchChange = useCallback((value: string) => { const handleSearchChange = useCallback(
setSearch(value); (value: string) => {
router.get(productIndex.url(), { setSearch(value);
page: 1, router.get(
per_page: pagination.per_page, productIndex.url(),
search: value, {
sort: sort.column, page: 1,
direction: sort.direction, per_page: pagination.per_page,
...filters, search: value,
}, { preserveState: true, replace: true }); sort: sort.column,
}, [pagination.per_page, sort, filters]); direction: sort.direction,
...filters,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort, filters],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') { function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction }); setSort({ column, direction });
router.get(productIndex.url(), { router.get(
page: 1, productIndex.url(),
per_page: pagination.per_page, {
search, page: 1,
sort: column, per_page: pagination.per_page,
direction, search,
...filters, sort: column,
}, { preserveState: true, replace: true }); direction,
...filters,
},
{ preserveState: true, replace: true },
);
} }
function handleDelete() { function handleDelete() {
@ -282,11 +355,18 @@ export default function ProductIndex({ products, filters }: Props) {
</label> </label>
<Combobox <Combobox
value={filters.name ?? ''} value={filters.name ?? ''}
onValueChange={(value) => applyFilter('name', value as string)} onValueChange={(value) =>
applyFilter('name', value as string)
}
> >
<ComboboxInput placeholder="Pilih produk..." className="w-full" /> <ComboboxInput
placeholder="Pilih produk..."
className="w-full"
/>
<ComboboxContent> <ComboboxContent>
<ComboboxEmpty>Tidak ada produk ditemukan.</ComboboxEmpty> <ComboboxEmpty>
Tidak ada produk ditemukan.
</ComboboxEmpty>
<ComboboxList> <ComboboxList>
{productNames.map((name) => ( {productNames.map((name) => (
<ComboboxItem key={name} value={name}> <ComboboxItem key={name} value={name}>
@ -304,15 +384,21 @@ export default function ProductIndex({ products, filters }: Props) {
</label> </label>
<Select <Select
value={filters.status ?? 'all'} value={filters.status ?? 'all'}
onValueChange={(value) => applyFilter('status', value)} onValueChange={(value) =>
applyFilter('status', value)
}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" /> <SelectValue placeholder="Semua Status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Semua Status</SelectItem> <SelectItem value="all">
Semua Status
</SelectItem>
<SelectItem value="active">Aktif</SelectItem> <SelectItem value="active">Aktif</SelectItem>
<SelectItem value="inactive">Non Aktif</SelectItem> <SelectItem value="inactive">
Non Aktif
</SelectItem>
<SelectItem value="draft">Draft</SelectItem> <SelectItem value="draft">Draft</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@ -354,7 +440,9 @@ export default function ProductIndex({ products, filters }: Props) {
onSortChange={handleSortChange} onSortChange={handleSortChange}
currentSort={sort} currentSort={sort}
searchValue={search} searchValue={search}
renderSubRow={(row, searchValue) => <VariantSubRow row={row} searchValue={searchValue} />} renderSubRow={(row, searchValue) => (
<VariantSubRow row={row} searchValue={searchValue} />
)}
defaultExpanded defaultExpanded
toolbar={filterToolbar} toolbar={filterToolbar}
/> />

View File

@ -30,9 +30,7 @@ export function createSupplierColumns(
id: 'no', id: 'no',
header: () => <span className="block text-center">No</span>, header: () => <span className="block text-center">No</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block text-center"> <span className="block text-center">{row.index + 1}</span>
{row.index + 1}
</span>
), ),
meta: { meta: {
className: 'w-[50px] text-center', className: 'w-[50px] text-center',
@ -46,9 +44,7 @@ export function createSupplierColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Nama</span> <span>Nama</span>
@ -68,9 +64,7 @@ export function createSupplierColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>No. Telepon</span> <span>No. Telepon</span>
@ -78,9 +72,7 @@ export function createSupplierColumns(
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span> <span>{(row.getValue('phone_number') as string) ?? '-'}</span>
{row.getValue('phone_number') as string ?? '-'}
</span>
), ),
}, },
{ {
@ -90,9 +82,7 @@ export function createSupplierColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Alamat</span> <span>Alamat</span>
@ -100,8 +90,8 @@ export function createSupplierColumns(
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span className="max-w-[200px] truncate block"> <span className="block max-w-[200px] truncate">
{row.getValue('address') as string ?? '-'} {(row.getValue('address') as string) ?? '-'}
</span> </span>
), ),
}, },
@ -123,16 +113,12 @@ export function createSupplierColumns(
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => onClick={() => handleEdit(supplier)}
handleEdit(supplier)
}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">Edit</TooltipContent>
Edit
</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>

View File

@ -16,7 +16,12 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { destroy, store, index as supplierIndex, update } from '@/routes/admin/master/suppliers'; import {
destroy,
store,
index as supplierIndex,
update,
} from '@/routes/admin/master/suppliers';
import type { Supplier } from './columns'; import type { Supplier } from './columns';
import { createSupplierColumns } from './columns'; import { createSupplierColumns } from './columns';
@ -35,7 +40,10 @@ export default function SupplierIndex({ suppliers }: Props) {
const [editing, setEditing] = useState<Supplier | null>(null); const [editing, setEditing] = useState<Supplier | null>(null);
const [deleting, setDeleting] = useState<Supplier | null>(null); const [deleting, setDeleting] = useState<Supplier | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' }); const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = { const pagination: PaginationState = {
current_page: suppliers.current_page, current_page: suppliers.current_page,
@ -45,57 +53,76 @@ export default function SupplierIndex({ suppliers }: Props) {
}; };
function handlePageChange(page: number) { function handlePageChange(page: number) {
router.get(supplierIndex.url(), { router.get(
page, supplierIndex.url(),
per_page: pagination.per_page, {
search, page,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { sort: sort.column,
preserveState: true, direction: sort.direction,
replace: true, },
}); {
preserveState: true,
replace: true,
},
);
} }
function handlePerPageChange(perPage: number) { function handlePerPageChange(perPage: number) {
router.get(supplierIndex.url(), { router.get(
page: 1, supplierIndex.url(),
per_page: perPage, {
search, page: 1,
sort: sort.column, per_page: perPage,
direction: sort.direction, search,
}, { sort: sort.column,
preserveState: true, direction: sort.direction,
replace: true, },
}); {
preserveState: true,
replace: true,
},
);
} }
const handleSearchChange = useCallback((value: string) => { const handleSearchChange = useCallback(
setSearch(value); (value: string) => {
router.get(supplierIndex.url(), { setSearch(value);
page: 1, router.get(
per_page: pagination.per_page, supplierIndex.url(),
search: value, {
sort: sort.column, page: 1,
direction: sort.direction, per_page: pagination.per_page,
}, { search: value,
preserveState: true, sort: sort.column,
replace: true, direction: sort.direction,
}); },
}, [pagination.per_page, sort]); {
preserveState: true,
replace: true,
},
);
},
[pagination.per_page, sort],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') { function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction }); setSort({ column, direction });
router.get(supplierIndex.url(), { router.get(
page: 1, supplierIndex.url(),
per_page: pagination.per_page, {
search, page: 1,
sort: column, per_page: pagination.per_page,
direction, search,
}, { sort: column,
preserveState: true, direction,
replace: true, },
}); {
preserveState: true,
replace: true,
},
);
} }
function handleDelete() { function handleDelete() {
@ -135,32 +162,46 @@ export default function SupplierIndex({ suppliers }: Props) {
</button> </button>
</Button> </Button>
<DialogContent> <DialogContent>
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}> <Form
action={store()}
resetOnSuccess
onSuccess={() => setCreateOpen(false)}
>
{({ errors, processing }) => { {({ errors, processing }) => {
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Tambah Supplier</DialogTitle> <DialogTitle>
Tambah Supplier
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="name"> <Label htmlFor="name">
Nama{' '} <span className="text-destructive">*</span> Nama{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="name" id="name"
name="name" name="name"
placeholder="Masukkan nama supplier" placeholder="Masukkan nama supplier"
/> />
<InputError message={errors.name} /> <InputError
message={errors.name}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="phone_number"> <Label htmlFor="phone_number">
No. Telepon No. Telepon
</Label> </Label>
<PhoneNumberInput name="phone_number"/> <PhoneNumberInput name="phone_number" />
<InputError message={errors.phone_number} /> <InputError
message={
errors.phone_number
}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="address"> <Label htmlFor="address">
@ -171,19 +212,23 @@ export default function SupplierIndex({ suppliers }: Props) {
name="address" name="address"
placeholder="Masukkan alamat" placeholder="Masukkan alamat"
/> />
<InputError message={errors.address} /> <InputError
message={errors.address}
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => setCreateOpen(false)} onClick={() =>
setCreateOpen(false)
}
> >
Batal Batal
</Button> </Button>
<Button <Button
type='submit' type="submit"
disabled={processing} disabled={processing}
> >
{processing {processing
@ -224,27 +269,43 @@ export default function SupplierIndex({ suppliers }: Props) {
> >
<DialogContent> <DialogContent>
{editing && ( {editing && (
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}> <Form
action={update(editing.id)}
resetOnSuccess
onSuccess={() => setEditing(null)}
>
{({ errors, processing }) => { {({ errors, processing }) => {
return ( return (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Supplier</DialogTitle> <DialogTitle>
Edit Supplier
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="edit-name">Nama{' '} <span className="text-destructive">*</span></Label> <Label htmlFor="edit-name">
Nama{' '}
<span className="text-destructive">
*
</span>
</Label>
<Input <Input
id="edit-name" id="edit-name"
name="name" name="name"
placeholder="Masukkan nama supplier" placeholder="Masukkan nama supplier"
defaultValue={editing.name} defaultValue={
editing.name
}
/>
<InputError
message={errors.name}
/> />
<InputError message={errors.name} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="edit-phone_number">No. Telepon</Label> <Label htmlFor="edit-phone_number">
No. Telepon
</Label>
<Input <Input
id="edit-phone_number" id="edit-phone_number"
name="phone_number" name="phone_number"
@ -252,19 +313,33 @@ export default function SupplierIndex({ suppliers }: Props) {
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
placeholder="Masukkan nomor telepon" placeholder="Masukkan nomor telepon"
defaultValue={editing.phone_number ?? ''} defaultValue={
editing.phone_number ??
''
}
/>
<InputError
message={
errors.phone_number
}
/> />
<InputError message={errors.phone_number} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="edit-address">Alamat</Label> <Label htmlFor="edit-address">
Alamat
</Label>
<Input <Input
id="edit-address" id="edit-address"
name="address" name="address"
placeholder="Masukkan alamat" placeholder="Masukkan alamat"
defaultValue={editing.address ?? ''} defaultValue={
editing.address ??
''
}
/>
<InputError
message={errors.address}
/> />
<InputError message={errors.address} />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>

View File

@ -29,9 +29,7 @@ export function createRoleColumns(
id: 'no', id: 'no',
header: () => <span className="block text-center">No</span>, header: () => <span className="block text-center">No</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block text-center"> <span className="block text-center">{row.index + 1}</span>
{row.index + 1}
</span>
), ),
meta: { meta: {
className: 'w-[50px] text-center', className: 'w-[50px] text-center',
@ -45,9 +43,7 @@ export function createRoleColumns(
variant="ghost" variant="ghost"
className="-ml-3 h-8" className="-ml-3 h-8"
onClick={() => onClick={() =>
column.toggleSorting( column.toggleSorting(column.getIsSorted() === 'asc')
column.getIsSorted() === 'asc',
)
} }
> >
<span>Nama Role</span> <span>Nama Role</span>
@ -62,7 +58,9 @@ export function createRoleColumns(
}, },
{ {
accessorKey: 'permissions_count', accessorKey: 'permissions_count',
header: () => <span className="block text-center">Jumlah Permission</span>, header: () => (
<span className="block text-center">Jumlah Permission</span>
),
meta: { meta: {
className: 'w-[180px] text-center', className: 'w-[180px] text-center',
headerClassName: 'w-[180px] text-center', headerClassName: 'w-[180px] text-center',
@ -96,9 +94,7 @@ export function createRoleColumns(
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top"> <TooltipContent side="top">Edit</TooltipContent>
Edit
</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>

View File

@ -15,41 +15,41 @@ type Props = {
}; };
const moduleLabels: Record<string, string> = { const moduleLabels: Record<string, string> = {
'user': 'Pengguna', user: 'Pengguna',
'category': 'Kategori', category: 'Kategori',
'supplier': 'Supplier', supplier: 'Supplier',
'customer': 'Customer', customer: 'Customer',
'cash-account': 'Kas Toko', 'cash-account': 'Kas Toko',
'expense': 'Pengeluaran', expense: 'Pengeluaran',
'employee-advance': 'Kasbon', 'employee-advance': 'Kasbon',
'payroll-period': 'Periode Gaji', 'payroll-period': 'Periode Gaji',
'payroll': 'Gaji', payroll: 'Gaji',
'payroll-adjustment': 'Adjustment Gaji', 'payroll-adjustment': 'Adjustment Gaji',
'leave-request': 'Cuti', 'leave-request': 'Cuti',
'attendance': 'Absensi', attendance: 'Absensi',
'settings': 'Pengaturan', settings: 'Pengaturan',
}; };
const actionLabels: Record<string, string> = { const actionLabels: Record<string, string> = {
'view': 'Lihat', view: 'Lihat',
'create': 'Tambah', create: 'Tambah',
'update': 'Edit', update: 'Edit',
'delete': 'Hapus', delete: 'Hapus',
'toggle-active': 'Aktif/Nonaktif', 'toggle-active': 'Aktif/Nonaktif',
'reset-password': 'Reset Kata Sandi', 'reset-password': 'Reset Kata Sandi',
'deposit': 'Setor', deposit: 'Setor',
'withdrawal': 'Tarik', withdrawal: 'Tarik',
'approve': 'Setujui', approve: 'Setujui',
'pay': 'Bayar', pay: 'Bayar',
'reject': 'Tolak', reject: 'Tolak',
'current': 'Periode Saat Ini', current: 'Periode Saat Ini',
'close': 'Tutup', close: 'Tutup',
'reopen': 'Buka Kembali', reopen: 'Buka Kembali',
'cancel': 'Batalkan', cancel: 'Batalkan',
'check-in': 'Check In', 'check-in': 'Check In',
'check-out': 'Check Out', 'check-out': 'Check Out',
'by-date': 'Lihat Per Tanggal', 'by-date': 'Lihat Per Tanggal',
'show': 'Detail', show: 'Detail',
'update-system': 'Update Sistem', 'update-system': 'Update Sistem',
'update-homepage': 'Update Homepage', 'update-homepage': 'Update Homepage',
'update-social-media': 'Update Media Sosial', 'update-social-media': 'Update Media Sosial',
@ -88,7 +88,10 @@ export default function RoleCreate({ permissions }: Props) {
<CardContent> <CardContent>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="name"> <Label htmlFor="name">
Nama Role <span className="text-destructive">*</span> Nama Role{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="name" id="name"
@ -105,35 +108,48 @@ export default function RoleCreate({ permissions }: Props) {
<CardTitle>Permission</CardTitle> <CardTitle>Permission</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid gap-6"> <CardContent className="grid gap-6">
{Object.entries(permissions).map(([module, actions]) => ( {Object.entries(permissions).map(
<div key={module} className="grid gap-3"> ([module, actions]) => (
<Label className="text-sm font-semibold"> <div
{moduleLabels[module] ?? module} key={module}
</Label> className="grid gap-3"
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4"> >
{actions.map((action) => ( <Label className="text-sm font-semibold">
<label {moduleLabels[module] ??
key={`${module}.${action}`} module}
className="flex items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted cursor-pointer" </Label>
> <div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
<Checkbox {actions.map(
name="permissions[]" (action) => (
value={`${module}.${action}`} <label
/> key={`${module}.${action}`}
<span className="text-xs"> className="flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted"
{actionLabels[action] ?? action} >
</span> <Checkbox
</label> name="permissions[]"
))} value={`${module}.${action}`}
/>
<span className="text-xs">
{actionLabels[
action
] ??
action}
</span>
</label>
),
)}
</div>
</div> </div>
</div> ),
))} )}
<InputError message={errors.permissions} /> <InputError
message={errors.permissions}
/>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<div className="flex items-center gap-4 mt-6"> <div className="mt-6 flex items-center gap-4">
<Button type="submit" disabled={processing}> <Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'} {processing ? 'Menyimpan...' : 'Simpan'}
</Button> </Button>

View File

@ -25,41 +25,41 @@ type Props = {
}; };
const moduleLabels: Record<string, string> = { const moduleLabels: Record<string, string> = {
'user': 'Pengguna', user: 'Pengguna',
'category': 'Kategori', category: 'Kategori',
'supplier': 'Supplier', supplier: 'Supplier',
'customer': 'Customer', customer: 'Customer',
'cash-account': 'Kas Toko', 'cash-account': 'Kas Toko',
'expense': 'Pengeluaran', expense: 'Pengeluaran',
'employee-advance': 'Kasbon', 'employee-advance': 'Kasbon',
'payroll-period': 'Periode Gaji', 'payroll-period': 'Periode Gaji',
'payroll': 'Gaji', payroll: 'Gaji',
'payroll-adjustment': 'Adjustment Gaji', 'payroll-adjustment': 'Adjustment Gaji',
'leave-request': 'Cuti', 'leave-request': 'Cuti',
'attendance': 'Absensi', attendance: 'Absensi',
'settings': 'Pengaturan', settings: 'Pengaturan',
}; };
const actionLabels: Record<string, string> = { const actionLabels: Record<string, string> = {
'view': 'Lihat', view: 'Lihat',
'create': 'Tambah', create: 'Tambah',
'update': 'Edit', update: 'Edit',
'delete': 'Hapus', delete: 'Hapus',
'toggle-active': 'Aktif/Nonaktif', 'toggle-active': 'Aktif/Nonaktif',
'reset-password': 'Reset Kata Sandi', 'reset-password': 'Reset Kata Sandi',
'deposit': 'Setor', deposit: 'Setor',
'withdrawal': 'Tarik', withdrawal: 'Tarik',
'approve': 'Setujui', approve: 'Setujui',
'pay': 'Bayar', pay: 'Bayar',
'reject': 'Tolak', reject: 'Tolak',
'current': 'Periode Saat Ini', current: 'Periode Saat Ini',
'close': 'Tutup', close: 'Tutup',
'reopen': 'Buka Kembali', reopen: 'Buka Kembali',
'cancel': 'Batalkan', cancel: 'Batalkan',
'check-in': 'Check In', 'check-in': 'Check In',
'check-out': 'Check Out', 'check-out': 'Check Out',
'by-date': 'Lihat Per Tanggal', 'by-date': 'Lihat Per Tanggal',
'show': 'Detail', show: 'Detail',
'update-system': 'Update Sistem', 'update-system': 'Update Sistem',
'update-homepage': 'Update Homepage', 'update-homepage': 'Update Homepage',
'update-social-media': 'Update Media Sosial', 'update-social-media': 'Update Media Sosial',
@ -100,7 +100,10 @@ export default function RoleEdit({ role, permissions }: Props) {
<CardContent> <CardContent>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="name"> <Label htmlFor="name">
Nama Role <span className="text-destructive">*</span> Nama Role{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="name" id="name"
@ -118,36 +121,51 @@ export default function RoleEdit({ role, permissions }: Props) {
<CardTitle>Permission</CardTitle> <CardTitle>Permission</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid gap-6"> <CardContent className="grid gap-6">
{Object.entries(permissions).map(([module, actions]) => ( {Object.entries(permissions).map(
<div key={module} className="grid gap-3"> ([module, actions]) => (
<Label className="text-sm font-semibold"> <div
{moduleLabels[module] ?? module} key={module}
</Label> className="grid gap-3"
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4"> >
{actions.map((action) => ( <Label className="text-sm font-semibold">
<label {moduleLabels[module] ??
key={`${module}.${action}`} module}
className="flex items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted cursor-pointer" </Label>
> <div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
<Checkbox {actions.map(
name="permissions[]" (action) => (
value={`${module}.${action}`} <label
defaultChecked={assignedPermissions.includes(`${module}.${action}`)} key={`${module}.${action}`}
/> className="flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted"
<span className="text-xs"> >
{actionLabels[action] ?? action} <Checkbox
</span> name="permissions[]"
</label> value={`${module}.${action}`}
))} defaultChecked={assignedPermissions.includes(
`${module}.${action}`,
)}
/>
<span className="text-xs">
{actionLabels[
action
] ??
action}
</span>
</label>
),
)}
</div>
</div> </div>
</div> ),
))} )}
<InputError message={errors.permissions} /> <InputError
message={errors.permissions}
/>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<div className="flex items-center gap-4 mt-6"> <div className="mt-6 flex items-center gap-4">
<Button type="submit" disabled={processing}> <Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'} {processing ? 'Menyimpan...' : 'Simpan'}
</Button> </Button>

View File

@ -2,12 +2,17 @@ import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import type {PaginationState, SortState} from '@/components/data-table'; import type { PaginationState, SortState } from '@/components/data-table';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { index as rolesIndex, create as roleCreate, edit as roleEdit, destroy as roleDestroy } from '@/routes/admin/settings/roles'; import {
import { createRoleColumns } from './columns'; index as rolesIndex,
import type {Role} from './columns'; create as roleCreate,
edit as roleEdit,
destroy as roleDestroy,
} from '@/routes/admin/settings/roles';
import { createRoleColumns } from './columns';
import type { Role } from './columns';
type Props = { type Props = {
roles: { roles: {
@ -22,7 +27,10 @@ type Props = {
export default function RoleIndex({ roles }: Props) { export default function RoleIndex({ roles }: Props) {
const [deleting, setDeleting] = useState<Role | null>(null); const [deleting, setDeleting] = useState<Role | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' }); const [sort, setSort] = useState<SortState>({
column: 'created_at',
direction: 'desc',
});
const pagination: PaginationState = { const pagination: PaginationState = {
current_page: roles.current_page, current_page: roles.current_page,
@ -32,45 +40,64 @@ export default function RoleIndex({ roles }: Props) {
}; };
function handlePageChange(page: number) { function handlePageChange(page: number) {
router.get(rolesIndex.url(), { router.get(
page, rolesIndex.url(),
per_page: pagination.per_page, {
search, page,
sort: sort.column, per_page: pagination.per_page,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
function handlePerPageChange(perPage: number) { function handlePerPageChange(perPage: number) {
router.get(rolesIndex.url(), { router.get(
page: 1, rolesIndex.url(),
per_page: perPage, {
search, page: 1,
sort: sort.column, per_page: perPage,
direction: sort.direction, search,
}, { preserveState: true, replace: true }); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
} }
const handleSearchChange = useCallback((value: string) => { const handleSearchChange = useCallback(
setSearch(value); (value: string) => {
router.get(rolesIndex.url(), { setSearch(value);
page: 1, router.get(
per_page: pagination.per_page, rolesIndex.url(),
search: value, {
sort: sort.column, page: 1,
direction: sort.direction, per_page: pagination.per_page,
}, { preserveState: true, replace: true }); search: value,
}, [pagination.per_page, sort]); sort: sort.column,
direction: sort.direction,
},
{ preserveState: true, replace: true },
);
},
[pagination.per_page, sort],
);
function handleSortChange(column: string, direction: 'asc' | 'desc') { function handleSortChange(column: string, direction: 'asc' | 'desc') {
setSort({ column, direction }); setSort({ column, direction });
router.get(rolesIndex.url(), { router.get(
page: 1, rolesIndex.url(),
per_page: pagination.per_page, {
search, page: 1,
sort: column, per_page: pagination.per_page,
direction, search,
}, { preserveState: true, replace: true }); sort: column,
direction,
},
{ preserveState: true, replace: true },
);
} }
function handleDelete() { function handleDelete() {

View File

@ -11,7 +11,13 @@ import { Separator } from '@/components/ui/separator';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { updateHomepage, updateHr, updateMarketplace, updateSocialMedia, updateSystem } from '@/routes/admin/settings'; import {
updateHomepage,
updateHr,
updateMarketplace,
updateSocialMedia,
updateSystem,
} from '@/routes/admin/settings';
import { Form, Head } from '@inertiajs/react'; import { Form, Head } from '@inertiajs/react';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
@ -60,11 +66,17 @@ type HomepageSettingsTabProps = {
}; };
function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) { function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
const [heroKey, setHeroKey] = useState<string | null>(homepage.hero_image_key ?? null); const [heroKey, setHeroKey] = useState<string | null>(
homepage.hero_image_key ?? null,
);
const [heroUploading, setHeroUploading] = useState(false); const [heroUploading, setHeroUploading] = useState(false);
const [aboutKey, setAboutKey] = useState<string | null>(homepage.about_image_key ?? null); const [aboutKey, setAboutKey] = useState<string | null>(
homepage.about_image_key ?? null,
);
const [aboutUploading, setAboutUploading] = useState(false); const [aboutUploading, setAboutUploading] = useState(false);
const [galleryKeys, setGalleryKeys] = useState<string[]>(homepage.gallery_image_keys ?? []); const [galleryKeys, setGalleryKeys] = useState<string[]>(
homepage.gallery_image_keys ?? [],
);
const [galleryUploading, setGalleryUploading] = useState(false); const [galleryUploading, setGalleryUploading] = useState(false);
function addGalleryImage() { function addGalleryImage() {
@ -76,7 +88,9 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
} }
function updateGalleryKey(index: number, key: string | null) { function updateGalleryKey(index: number, key: string | null) {
setGalleryKeys((prev) => prev.map((k, i) => (i === index ? (key ?? '') : k))); setGalleryKeys((prev) =>
prev.map((k, i) => (i === index ? (key ?? '') : k)),
);
} }
return ( return (
@ -88,10 +102,23 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
<CardTitle>Homepage</CardTitle> <CardTitle>Homepage</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid grid-cols-1 gap-4"> <CardContent className="grid grid-cols-1 gap-4">
<input type="hidden" name="hero_image_key" value={heroKey ?? ''} /> <input
<input type="hidden" name="about_image_key" value={aboutKey ?? ''} /> type="hidden"
name="hero_image_key"
value={heroKey ?? ''}
/>
<input
type="hidden"
name="about_image_key"
value={aboutKey ?? ''}
/>
{galleryKeys.map((key, index) => ( {galleryKeys.map((key, index) => (
<input key={index} type="hidden" name={`gallery_image_keys[${index}]`} value={key} /> <input
key={index}
type="hidden"
name={`gallery_image_keys[${index}]`}
value={key}
/>
))} ))}
<div className="grid gap-2"> <div className="grid gap-2">
@ -133,14 +160,28 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
</div> </div>
<div className="grid gap-3"> <div className="grid gap-3">
{galleryKeys.map((key, index) => ( {galleryKeys.map((key, index) => (
<div key={index} className="flex items-start gap-2"> <div
key={index}
className="flex items-start gap-2"
>
<div className="flex-1"> <div className="flex-1">
<FileUpload <FileUpload
value={key || null} value={key || null}
onChange={(k) => updateGalleryKey(index, k)} onChange={(k) =>
updateGalleryKey(
index,
k,
)
}
folder="homepage/gallery" folder="homepage/gallery"
existingUrl={homepage.gallery_images[index] ?? null} existingUrl={
onUploadingChange={setGalleryUploading} homepage.gallery_images[
index
] ?? null
}
onUploadingChange={
setGalleryUploading
}
/> />
</div> </div>
<Button <Button
@ -148,22 +189,37 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="mt-1 text-destructive hover:text-destructive" className="mt-1 text-destructive hover:text-destructive"
onClick={() => removeGalleryImage(index)} onClick={() =>
removeGalleryImage(index)
}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
))} ))}
{galleryKeys.length === 0 && ( {galleryKeys.length === 0 && (
<p className="text-sm text-muted-foreground">Belum ada gambar. Klik "Tambah" untuk menambahkan gambar galeri.</p> <p className="text-sm text-muted-foreground">
Belum ada gambar. Klik "Tambah"
untuk menambahkan gambar galeri.
</p>
)} )}
</div> </div>
<InputError message={errors.gallery_image_keys} /> <InputError
message={errors.gallery_image_keys}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button type="submit" disabled={processing || heroUploading || aboutUploading || galleryUploading}> <Button
type="submit"
disabled={
processing ||
heroUploading ||
aboutUploading ||
galleryUploading
}
>
{processing ? 'Menyimpan...' : 'Simpan'} {processing ? 'Menyimpan...' : 'Simpan'}
</Button> </Button>
</div> </div>
@ -183,43 +239,103 @@ const sidebarTabs = [
type TabKey = (typeof sidebarTabs)[number]['key']; type TabKey = (typeof sidebarTabs)[number]['key'];
function MarketplaceVariableInput({ label, prefix, data }: { label: string; prefix: string; data: MarketplaceFeeRule }) { function MarketplaceVariableInput({
label,
prefix,
data,
}: {
label: string;
prefix: string;
data: MarketplaceFeeRule;
}) {
const [type, setType] = useState(data.type); const [type, setType] = useState(data.type);
return ( return (
<div className="flex flex-col gap-3 rounded-md border p-3 md:flex-row md:items-start md:gap-4"> <div className="flex flex-col gap-3 rounded-md border p-3 md:flex-row md:items-start md:gap-4">
<span className="min-w-[160px] text-sm font-medium md:pt-2">{label}</span> <span className="min-w-[160px] text-sm font-medium md:pt-2">
{label}
</span>
<div className="grid flex-1 grid-cols-1 gap-3 md:grid-cols-3"> <div className="grid flex-1 grid-cols-1 gap-3 md:grid-cols-3">
<div className="grid gap-2"> <div className="grid gap-2">
<span className="text-xs font-medium text-muted-foreground md:hidden">Dasar</span> <span className="text-xs font-medium text-muted-foreground md:hidden">
<RadioGroup name={`${prefix}[base]`} defaultValue={data.base} className="flex gap-4"> Dasar
</span>
<RadioGroup
name={`${prefix}[base]`}
defaultValue={data.base}
className="flex gap-4"
>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="per_transaksi" id={`${prefix}-base-per_transaksi`} /> <RadioGroupItem
<Label htmlFor={`${prefix}-base-per_transaksi`} className="font-normal text-xs">Per Transaksi</Label> value="per_transaksi"
id={`${prefix}-base-per_transaksi`}
/>
<Label
htmlFor={`${prefix}-base-per_transaksi`}
className="text-xs font-normal"
>
Per Transaksi
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="per_produk" id={`${prefix}-base-per_produk`} /> <RadioGroupItem
<Label htmlFor={`${prefix}-base-per_produk`} className="font-normal text-xs">Per Produk</Label> value="per_produk"
id={`${prefix}-base-per_produk`}
/>
<Label
htmlFor={`${prefix}-base-per_produk`}
className="text-xs font-normal"
>
Per Produk
</Label>
</div> </div>
</RadioGroup> </RadioGroup>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<span className="text-xs font-medium text-muted-foreground md:hidden">Tipe</span> <span className="text-xs font-medium text-muted-foreground md:hidden">
<RadioGroup name={`${prefix}[type]`} defaultValue={data.type} onValueChange={setType} className="flex gap-4"> Tipe
</span>
<RadioGroup
name={`${prefix}[type]`}
defaultValue={data.type}
onValueChange={setType}
className="flex gap-4"
>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="flat" id={`${prefix}-type-flat`} /> <RadioGroupItem
<Label htmlFor={`${prefix}-type-flat`} className="font-normal text-xs">Flat</Label> value="flat"
id={`${prefix}-type-flat`}
/>
<Label
htmlFor={`${prefix}-type-flat`}
className="text-xs font-normal"
>
Flat
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="persentase" id={`${prefix}-type-persentase`} /> <RadioGroupItem
<Label htmlFor={`${prefix}-type-persentase`} className="font-normal text-xs">Persentase</Label> value="persentase"
id={`${prefix}-type-persentase`}
/>
<Label
htmlFor={`${prefix}-type-persentase`}
className="text-xs font-normal"
>
Persentase
</Label>
</div> </div>
</RadioGroup> </RadioGroup>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<span className="text-xs font-medium text-muted-foreground md:hidden">Nilai</span> <span className="text-xs font-medium text-muted-foreground md:hidden">
Nilai
</span>
{type === 'flat' ? ( {type === 'flat' ? (
<RupiahInput name={`${prefix}[value]`} defaultValue={data.value} /> <RupiahInput
name={`${prefix}[value]`}
defaultValue={data.value}
/>
) : ( ) : (
<Input <Input
name={`${prefix}[value]`} name={`${prefix}[value]`}
@ -249,7 +365,13 @@ function MarketplaceColumnHeader() {
); );
} }
function MarketplaceCard({ title, variables }: { title: string; variables: { label: string; prefix: string; data: MarketplaceFeeRule }[] }) { function MarketplaceCard({
title,
variables,
}: {
title: string;
variables: { label: string; prefix: string; data: MarketplaceFeeRule }[];
}) {
return ( return (
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@ -265,29 +387,97 @@ function MarketplaceCard({ title, variables }: { title: string; variables: { lab
); );
} }
export default function AdminSettings({ system, homepage, socialMedia, marketplace, hr }: Props) { export default function AdminSettings({
system,
homepage,
socialMedia,
marketplace,
hr,
}: Props) {
const [activeTab, setActiveTab] = useState<TabKey>('sistem'); const [activeTab, setActiveTab] = useState<TabKey>('sistem');
const [marketplaceTab, setMarketplaceTab] = useState<'tiktok-shop' | 'shopee'>('tiktok-shop'); const [marketplaceTab, setMarketplaceTab] = useState<
'tiktok-shop' | 'shopee'
>('tiktok-shop');
const tiktokShopVariables = [ const tiktokShopVariables = [
{ label: 'Komisi Platform', prefix: 'tiktok_shop_platform_commission', data: marketplace.tiktok_shop.platform_commission }, {
{ label: 'Layanan Logistik', prefix: 'tiktok_shop_logistics_service_fee', data: marketplace.tiktok_shop.logistics_service_fee }, label: 'Komisi Platform',
{ label: 'Komisi Dinamis', prefix: 'tiktok_shop_dynamic_commission', data: marketplace.tiktok_shop.dynamic_commission }, prefix: 'tiktok_shop_platform_commission',
{ label: 'Pemrosesan Pesanan', prefix: 'tiktok_shop_order_processing_fee', data: marketplace.tiktok_shop.order_processing_fee }, data: marketplace.tiktok_shop.platform_commission,
{ label: 'Affiliate', prefix: 'tiktok_shop_affiliate', data: marketplace.tiktok_shop.affiliate }, },
{ label: 'Layanan PO', prefix: 'tiktok_shop_pre_order_service_fee', data: marketplace.tiktok_shop.pre_order_service_fee }, {
label: 'Layanan Logistik',
prefix: 'tiktok_shop_logistics_service_fee',
data: marketplace.tiktok_shop.logistics_service_fee,
},
{
label: 'Komisi Dinamis',
prefix: 'tiktok_shop_dynamic_commission',
data: marketplace.tiktok_shop.dynamic_commission,
},
{
label: 'Pemrosesan Pesanan',
prefix: 'tiktok_shop_order_processing_fee',
data: marketplace.tiktok_shop.order_processing_fee,
},
{
label: 'Affiliate',
prefix: 'tiktok_shop_affiliate',
data: marketplace.tiktok_shop.affiliate,
},
{
label: 'Layanan PO',
prefix: 'tiktok_shop_pre_order_service_fee',
data: marketplace.tiktok_shop.pre_order_service_fee,
},
]; ];
const shopeeVariables = [ const shopeeVariables = [
{ label: 'Biaya Administrasi', prefix: 'shopee_admin_fee', data: marketplace.shopee.admin_fee }, {
{ label: 'Biaya Program', prefix: 'shopee_program_fee', data: marketplace.shopee.program_fee }, label: 'Biaya Administrasi',
{ label: 'Hemat Biaya Kirim', prefix: 'shopee_shipping_savings', data: marketplace.shopee.shipping_savings }, prefix: 'shopee_admin_fee',
{ label: 'Premi', prefix: 'shopee_premium', data: marketplace.shopee.premium }, data: marketplace.shopee.admin_fee,
{ label: 'Biaya Layanan', prefix: 'shopee_service_fee', data: marketplace.shopee.service_fee }, },
{ label: 'Biaya Proses Pesanan', prefix: 'shopee_order_processing_fee', data: marketplace.shopee.order_processing_fee }, {
{ label: 'Biaya Komisi AMS', prefix: 'shopee_ams_commission_fee', data: marketplace.shopee.ams_commission_fee }, label: 'Biaya Program',
{ label: 'PO', prefix: 'shopee_pre_order', data: marketplace.shopee.pre_order }, prefix: 'shopee_program_fee',
{ label: 'Live Extra', prefix: 'shopee_live_extra', data: marketplace.shopee.live_extra }, data: marketplace.shopee.program_fee,
},
{
label: 'Hemat Biaya Kirim',
prefix: 'shopee_shipping_savings',
data: marketplace.shopee.shipping_savings,
},
{
label: 'Premi',
prefix: 'shopee_premium',
data: marketplace.shopee.premium,
},
{
label: 'Biaya Layanan',
prefix: 'shopee_service_fee',
data: marketplace.shopee.service_fee,
},
{
label: 'Biaya Proses Pesanan',
prefix: 'shopee_order_processing_fee',
data: marketplace.shopee.order_processing_fee,
},
{
label: 'Biaya Komisi AMS',
prefix: 'shopee_ams_commission_fee',
data: marketplace.shopee.ams_commission_fee,
},
{
label: 'PO',
prefix: 'shopee_pre_order',
data: marketplace.shopee.pre_order,
},
{
label: 'Live Extra',
prefix: 'shopee_live_extra',
data: marketplace.shopee.live_extra,
},
]; ];
return ( return (
@ -305,7 +495,10 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
<div className="flex flex-col lg:flex-row lg:space-x-12"> <div className="flex flex-col lg:flex-row lg:space-x-12">
<aside className="w-full max-w-xl lg:w-48"> <aside className="w-full max-w-xl lg:w-48">
<nav className="flex flex-col space-y-1 space-x-0" aria-label="Admin Settings"> <nav
className="flex flex-col space-y-1 space-x-0"
aria-label="Admin Settings"
>
{sidebarTabs.map((tab) => ( {sidebarTabs.map((tab) => (
<Button <Button
key={tab.key} key={tab.key}
@ -326,7 +519,10 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
<section className="w-full min-w-0 flex-1 space-y-6"> <section className="w-full min-w-0 flex-1 space-y-6">
{activeTab === 'sistem' && ( {activeTab === 'sistem' && (
<Form action={updateSystem()} options={{ preserveScroll: true }}> <Form
action={updateSystem()}
options={{ preserveScroll: true }}
>
{({ processing, errors }) => ( {({ processing, errors }) => (
<div className="grid gap-6"> <div className="grid gap-6">
<Card> <Card>
@ -336,35 +532,103 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2"> <div className="grid gap-2 md:col-span-2">
<Label htmlFor="app_name"> <Label htmlFor="app_name">
Nama Aplikasi <span className="text-destructive">*</span> Nama Aplikasi{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input id="app_name" name="app_name" placeholder="Masukkan nama aplikasi" defaultValue={system.app_name} /> <Input
<InputError message={errors.app_name} /> id="app_name"
name="app_name"
placeholder="Masukkan nama aplikasi"
defaultValue={
system.app_name
}
/>
<InputError
message={
errors.app_name
}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">
<Input id="email" name="email" type="email" placeholder="Masukkan email" defaultValue={system.email} /> Email
<InputError message={errors.email} /> </Label>
<Input
id="email"
name="email"
type="email"
placeholder="Masukkan email"
defaultValue={
system.email
}
/>
<InputError
message={errors.email}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="phone">No. Telepon</Label> <Label htmlFor="phone">
<PhoneNumberInput name="phone" defaultValue={system.phone} /> No. Telepon
<InputError message={errors.phone} /> </Label>
<PhoneNumberInput
name="phone"
defaultValue={
system.phone
}
/>
<InputError
message={errors.phone}
/>
</div> </div>
<div className="grid gap-2 md:col-span-2"> <div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">Alamat</Label> <Label htmlFor="address">
<Textarea id="address" name="address" placeholder="Masukkan alamat" rows={3} defaultValue={system.address} /> Alamat
<InputError message={errors.address} /> </Label>
<Textarea
id="address"
name="address"
placeholder="Masukkan alamat"
rows={3}
defaultValue={
system.address
}
/>
<InputError
message={errors.address}
/>
</div> </div>
<div className="grid gap-2 md:col-span-2"> <div className="grid gap-2 md:col-span-2">
<Label htmlFor="about_app">Tentang Aplikasi</Label> <Label htmlFor="about_app">
<Textarea id="about_app" name="about_app" placeholder="Masukkan deskripsi aplikasi" rows={4} defaultValue={system.about_app} /> Tentang Aplikasi
<InputError message={errors.about_app} /> </Label>
<Textarea
id="about_app"
name="about_app"
placeholder="Masukkan deskripsi aplikasi"
rows={4}
defaultValue={
system.about_app
}
/>
<InputError
message={
errors.about_app
}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>{processing ? 'Menyimpan...' : 'Simpan'}</Button> <Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</div> </div>
</div> </div>
)} )}
@ -376,33 +640,87 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
)} )}
{activeTab === 'media-sosial' && ( {activeTab === 'media-sosial' && (
<Form action={updateSocialMedia()} options={{ preserveScroll: true }}> <Form
action={updateSocialMedia()}
options={{ preserveScroll: true }}
>
{({ processing, errors }) => ( {({ processing, errors }) => (
<div className="grid gap-6"> <div className="grid gap-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Media Sosial</CardTitle> <CardTitle>
Media Sosial
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="instagram_url">Instagram</Label> <Label htmlFor="instagram_url">
<Input id="instagram_url" name="instagram_url" placeholder="https://instagram.com/..." defaultValue={socialMedia.instagram_url ?? ''} /> Instagram
<InputError message={errors.instagram_url} /> </Label>
<Input
id="instagram_url"
name="instagram_url"
placeholder="https://instagram.com/..."
defaultValue={
socialMedia.instagram_url ??
''
}
/>
<InputError
message={
errors.instagram_url
}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="facebook_url">Facebook</Label> <Label htmlFor="facebook_url">
<Input id="facebook_url" name="facebook_url" placeholder="https://facebook.com/..." defaultValue={socialMedia.facebook_url ?? ''} /> Facebook
<InputError message={errors.facebook_url} /> </Label>
<Input
id="facebook_url"
name="facebook_url"
placeholder="https://facebook.com/..."
defaultValue={
socialMedia.facebook_url ??
''
}
/>
<InputError
message={
errors.facebook_url
}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="tiktok_url">TikTok</Label> <Label htmlFor="tiktok_url">
<Input id="tiktok_url" name="tiktok_url" placeholder="https://tiktok.com/..." defaultValue={socialMedia.tiktok_url ?? ''} /> TikTok
<InputError message={errors.tiktok_url} /> </Label>
<Input
id="tiktok_url"
name="tiktok_url"
placeholder="https://tiktok.com/..."
defaultValue={
socialMedia.tiktok_url ??
''
}
/>
<InputError
message={
errors.tiktok_url
}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>{processing ? 'Menyimpan...' : 'Simpan'}</Button> <Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</div> </div>
</div> </div>
)} )}
@ -410,23 +728,58 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
)} )}
{activeTab === 'marketplace' && ( {activeTab === 'marketplace' && (
<Form action={updateMarketplace()} options={{ preserveScroll: true }}> <Form
action={updateMarketplace()}
options={{ preserveScroll: true }}
>
{({ processing }) => ( {({ processing }) => (
<div className="grid gap-6"> <div className="grid gap-6">
<Tabs value={marketplaceTab} onValueChange={(v) => setMarketplaceTab(v as 'tiktok-shop' | 'shopee')}> <Tabs
value={marketplaceTab}
onValueChange={(v) =>
setMarketplaceTab(
v as
| 'tiktok-shop'
| 'shopee',
)
}
>
<TabsList className="mb-3"> <TabsList className="mb-3">
<TabsTrigger value="tiktok-shop">TikTok Shop</TabsTrigger> <TabsTrigger value="tiktok-shop">
<TabsTrigger value="shopee">Shopee</TabsTrigger> TikTok Shop
</TabsTrigger>
<TabsTrigger value="shopee">
Shopee
</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
<div hidden={marketplaceTab !== 'tiktok-shop'}> <div
<MarketplaceCard title="TikTok Shop" variables={tiktokShopVariables} /> hidden={
marketplaceTab !== 'tiktok-shop'
}
>
<MarketplaceCard
title="TikTok Shop"
variables={tiktokShopVariables}
/>
</div> </div>
<div hidden={marketplaceTab !== 'shopee'}> <div
<MarketplaceCard title="Shopee" variables={shopeeVariables} /> hidden={marketplaceTab !== 'shopee'}
>
<MarketplaceCard
title="Shopee"
variables={shopeeVariables}
/>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>{processing ? 'Menyimpan...' : 'Simpan'}</Button> <Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</div> </div>
</div> </div>
)} )}
@ -434,7 +787,10 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
)} )}
{activeTab === 'hr' && ( {activeTab === 'hr' && (
<Form action={updateHr()} options={{ preserveScroll: true }}> <Form
action={updateHr()}
options={{ preserveScroll: true }}
>
{({ processing, errors }) => ( {({ processing, errors }) => (
<div className="grid gap-6"> <div className="grid gap-6">
<Card> <Card>
@ -444,36 +800,95 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="scheduled_check_in_time"> <Label htmlFor="scheduled_check_in_time">
Jam Masuk Kerja <span className="text-destructive">*</span> Jam Masuk Kerja{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input id="scheduled_check_in_time" name="scheduled_check_in_time" type="time" defaultValue={hr.scheduled_check_in_time} /> <Input
<InputError message={errors.scheduled_check_in_time} /> id="scheduled_check_in_time"
name="scheduled_check_in_time"
type="time"
defaultValue={
hr.scheduled_check_in_time
}
/>
<InputError
message={
errors.scheduled_check_in_time
}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="scheduled_check_out_time"> <Label htmlFor="scheduled_check_out_time">
Jam Pulang Kerja <span className="text-destructive">*</span> Jam Pulang Kerja{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input id="scheduled_check_out_time" name="scheduled_check_out_time" type="time" defaultValue={hr.scheduled_check_out_time} /> <Input
<InputError message={errors.scheduled_check_out_time} /> id="scheduled_check_out_time"
name="scheduled_check_out_time"
type="time"
defaultValue={
hr.scheduled_check_out_time
}
/>
<InputError
message={
errors.scheduled_check_out_time
}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Denda Keterlambatan <span className="text-destructive">*</span> Denda Keterlambatan{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<RupiahInput name="late_penalty_amount" defaultValue={hr.late_penalty_amount} /> <RupiahInput
<InputError message={errors.late_penalty_amount} /> name="late_penalty_amount"
defaultValue={
hr.late_penalty_amount
}
/>
<InputError
message={
errors.late_penalty_amount
}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label> <Label>
Denda Bolos <span className="text-destructive">*</span> Denda Bolos{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<RupiahInput name="absent_penalty_amount" defaultValue={hr.absent_penalty_amount} /> <RupiahInput
<InputError message={errors.absent_penalty_amount} /> name="absent_penalty_amount"
defaultValue={
hr.absent_penalty_amount
}
/>
<InputError
message={
errors.absent_penalty_amount
}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button type="submit" disabled={processing}>{processing ? 'Menyimpan...' : 'Simpan'}</Button> <Button
type="submit"
disabled={processing}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</div> </div>
</div> </div>
)} )}

View File

@ -14,7 +14,7 @@ export default function Login() {
<Head title="Masuk Akun" /> <Head title="Masuk Akun" />
<Form <Form
action={store()} action={store()}
resetOnSuccess={['password']} resetOnSuccess={['password']}
className="flex flex-col gap-6" className="flex flex-col gap-6"
> >
@ -43,7 +43,9 @@ export default function Login() {
<div className="flex items-center"> <div className="flex items-center">
<Label htmlFor="password"> <Label htmlFor="password">
Kata Sandi Kata Sandi
<span className="text-destructive">*</span> <span className="text-destructive">
*
</span>
</Label> </Label>
</div> </div>
<PasswordInput <PasswordInput

View File

@ -8,7 +8,10 @@ import { useEffect, useState } from 'react';
type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown'; type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown';
const isPWA = () => { const isPWA = () => {
return window.matchMedia('(display-mode: standalone)').matches || (window.navigator as { standalone?: boolean }).standalone === true; return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as { standalone?: boolean }).standalone === true
);
}; };
const getPlatform = (): 'android' | 'ios' | 'desktop' => { const getPlatform = (): 'android' | 'ios' | 'desktop' => {
@ -36,7 +39,8 @@ const getDeniedMessage = (appName: string): string => {
}; };
export default function Permissions() { export default function Permissions() {
const [notifications, setNotifications] = useState<PermissionStatus>('unknown'); const [notifications, setNotifications] =
useState<PermissionStatus>('unknown');
const [camera, setCamera] = useState<PermissionStatus>('unknown'); const [camera, setCamera] = useState<PermissionStatus>('unknown');
const [location, setLocation] = useState<PermissionStatus>('unknown'); const [location, setLocation] = useState<PermissionStatus>('unknown');
@ -44,24 +48,32 @@ export default function Permissions() {
useEffect(() => { useEffect(() => {
if ('Notification' in window) { if ('Notification' in window) {
if (Notification.permission === 'granted') setNotifications('granted'); if (Notification.permission === 'granted')
else if (Notification.permission === 'denied') setNotifications('denied'); setNotifications('granted');
else if (Notification.permission === 'denied')
setNotifications('denied');
else setNotifications('prompt'); else setNotifications('prompt');
} }
if (navigator.mediaDevices) { if (navigator.mediaDevices) {
navigator.mediaDevices.getUserMedia({ video: true }).then((stream) => { navigator.mediaDevices
stream.getTracks().forEach((track) => track.stop()); .getUserMedia({ video: true })
setCamera('granted'); .then((stream) => {
}).catch(() => { stream.getTracks().forEach((track) => track.stop());
if ('permissions' in navigator) { setCamera('granted');
navigator.permissions.query({ name: 'camera' as PermissionName }).then((result) => { })
setCamera(result.state as PermissionStatus); .catch(() => {
}).catch(() => setCamera('denied')); if ('permissions' in navigator) {
} else { navigator.permissions
setCamera('denied'); .query({ name: 'camera' as PermissionName })
} .then((result) => {
}); setCamera(result.state as PermissionStatus);
})
.catch(() => setCamera('denied'));
} else {
setCamera('denied');
}
});
} else { } else {
setCamera('denied'); setCamera('denied');
} }
@ -71,9 +83,12 @@ export default function Permissions() {
() => setLocation('granted'), () => setLocation('granted'),
() => { () => {
if ('permissions' in navigator) { if ('permissions' in navigator) {
navigator.permissions.query({ name: 'geolocation' }).then((result) => { navigator.permissions
setLocation(result.state as PermissionStatus); .query({ name: 'geolocation' })
}).catch(() => setLocation('denied')); .then((result) => {
setLocation(result.state as PermissionStatus);
})
.catch(() => setLocation('denied'));
} else { } else {
setLocation('denied'); setLocation('denied');
} }
@ -92,7 +107,13 @@ export default function Permissions() {
return; return;
} }
const result = await Notification.requestPermission(); const result = await Notification.requestPermission();
setNotifications(result === 'granted' ? 'granted' : result === 'denied' ? 'denied' : 'prompt'); setNotifications(
result === 'granted'
? 'granted'
: result === 'denied'
? 'denied'
: 'prompt',
);
} else { } else {
setNotifications('prompt'); setNotifications('prompt');
} }
@ -101,7 +122,9 @@ export default function Permissions() {
const handleCamera = async (checked: boolean) => { const handleCamera = async (checked: boolean) => {
if (checked) { if (checked) {
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true }); const stream = await navigator.mediaDevices.getUserMedia({
video: true,
});
stream.getTracks().forEach((track) => track.stop()); stream.getTracks().forEach((track) => track.stop());
setCamera('granted'); setCamera('granted');
} catch { } catch {
@ -133,13 +156,18 @@ export default function Permissions() {
<h1 className="sr-only">Izin</h1> <h1 className="sr-only">Izin</h1>
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto px-4 md:px-6"> <div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto px-4 md:px-6">
<Alert variant={isDenied(notifications) ? 'destructive' : 'default'}> <Alert
variant={
isDenied(notifications) ? 'destructive' : 'default'
}
>
<Bell className="h-4 w-4" /> <Bell className="h-4 w-4" />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div className="flex-1">
<AlertTitle>Notifikasi</AlertTitle> <AlertTitle>Notifikasi</AlertTitle>
<AlertDescription> <AlertDescription>
Izinkan aplikasi mengirimkan notifikasi push ke perangkat Anda. Izinkan aplikasi mengirimkan notifikasi push ke
perangkat Anda.
</AlertDescription> </AlertDescription>
{isDenied(notifications) && ( {isDenied(notifications) && (
<p className="mt-1 text-sm text-destructive"> <p className="mt-1 text-sm text-destructive">
@ -163,7 +191,8 @@ export default function Permissions() {
<div className="flex-1"> <div className="flex-1">
<AlertTitle>Kamera</AlertTitle> <AlertTitle>Kamera</AlertTitle>
<AlertDescription> <AlertDescription>
Izinkan aplikasi mengakses kamera perangkat Anda untuk mengambil foto atau video. Izinkan aplikasi mengakses kamera perangkat Anda
untuk mengambil foto atau video.
</AlertDescription> </AlertDescription>
{isDenied(camera) && ( {isDenied(camera) && (
<p className="mt-1 text-sm text-destructive"> <p className="mt-1 text-sm text-destructive">
@ -187,7 +216,8 @@ export default function Permissions() {
<div className="flex-1"> <div className="flex-1">
<AlertTitle>Lokasi</AlertTitle> <AlertTitle>Lokasi</AlertTitle>
<AlertDescription> <AlertDescription>
Izinkan aplikasi mengakses lokasi perangkat Anda untuk menyediakan layanan berbasis lokasi. Izinkan aplikasi mengakses lokasi perangkat Anda
untuk menyediakan layanan berbasis lokasi.
</AlertDescription> </AlertDescription>
{isDenied(location) && ( {isDenied(location) && (
<p className="mt-1 text-sm text-destructive"> <p className="mt-1 text-sm text-destructive">

View File

@ -33,7 +33,9 @@ type Props = {
export default function Profile({ user }: Props) { export default function Profile({ user }: Props) {
const [birthDate, setBirthDate] = useState<Date | undefined>( const [birthDate, setBirthDate] = useState<Date | undefined>(
user.userProfile?.birth_date ? new Date(user.userProfile.birth_date) : undefined user.userProfile?.birth_date
? new Date(user.userProfile.birth_date)
: undefined,
); );
return ( return (
@ -56,7 +58,10 @@ export default function Profile({ user }: Props) {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="email"> <Label htmlFor="email">
Email <span className="text-destructive">*</span> Email{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="email" id="email"
@ -69,7 +74,10 @@ export default function Profile({ user }: Props) {
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="username"> <Label htmlFor="username">
Username <span className="text-destructive">*</span> Username{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="username" id="username"
@ -89,38 +97,71 @@ export default function Profile({ user }: Props) {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="full_name"> <Label htmlFor="full_name">
Nama Lengkap <span className="text-destructive">*</span> Nama Lengkap{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<Input <Input
id="full_name" id="full_name"
name="full_name" name="full_name"
placeholder="Masukkan nama lengkap" placeholder="Masukkan nama lengkap"
defaultValue={user.userProfile?.full_name ?? ''} defaultValue={
user.userProfile?.full_name ??
''
}
/>
<InputError
message={errors.full_name}
/> />
<InputError message={errors.full_name} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="phone_number">No. Telepon</Label> <Label htmlFor="phone_number">
No. Telepon
</Label>
<PhoneNumberInput <PhoneNumberInput
name="phone_number" name="phone_number"
defaultValue={user.userProfile?.phone_number ?? ''} defaultValue={
user.userProfile
?.phone_number ?? ''
}
/>
<InputError
message={errors.phone_number}
/> />
<InputError message={errors.phone_number} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Jenis Kelamin</Label> <Label>Jenis Kelamin</Label>
<RadioGroup <RadioGroup
name="gender" name="gender"
defaultValue={user.userProfile?.gender ?? ''} defaultValue={
user.userProfile?.gender ?? ''
}
className="flex gap-4" className="flex gap-4"
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="male" id="settings-gender-male" /> <RadioGroupItem
<Label htmlFor="settings-gender-male" className="font-normal">Laki-laki</Label> value="male"
id="settings-gender-male"
/>
<Label
htmlFor="settings-gender-male"
className="font-normal"
>
Laki-laki
</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="female" id="settings-gender-female" /> <RadioGroupItem
<Label htmlFor="settings-gender-female" className="font-normal">Perempuan</Label> value="female"
id="settings-gender-female"
/>
<Label
htmlFor="settings-gender-female"
className="font-normal"
>
Perempuan
</Label>
</div> </div>
</RadioGroup> </RadioGroup>
<InputError message={errors.gender} /> <InputError message={errors.gender} />
@ -133,7 +174,9 @@ export default function Profile({ user }: Props) {
onChange={setBirthDate} onChange={setBirthDate}
placeholder="Pilih tanggal lahir" placeholder="Pilih tanggal lahir"
/> />
<InputError message={errors.birth_date} /> <InputError
message={errors.birth_date}
/>
</div> </div>
<div className="grid gap-2 md:col-span-2"> <div className="grid gap-2 md:col-span-2">
<Label htmlFor="address">Alamat</Label> <Label htmlFor="address">Alamat</Label>
@ -142,7 +185,9 @@ export default function Profile({ user }: Props) {
name="address" name="address"
placeholder="Masukkan alamat" placeholder="Masukkan alamat"
rows={3} rows={3}
defaultValue={user.userProfile?.address ?? ''} defaultValue={
user.userProfile?.address ?? ''
}
/> />
<InputError message={errors.address} /> <InputError message={errors.address} />
</div> </div>

View File

@ -45,7 +45,10 @@ export default function Security(props: Props) {
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3"> <CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="current_password"> <Label htmlFor="current_password">
Kata Sandi Saat <span className="text-destructive">*</span> Kata Sandi Saat{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<PasswordInput <PasswordInput
@ -57,12 +60,17 @@ export default function Security(props: Props) {
placeholder="Masukkan kata sandi saat ini" placeholder="Masukkan kata sandi saat ini"
/> />
<InputError message={errors.current_password} /> <InputError
message={errors.current_password}
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="password"> <Label htmlFor="password">
Kata Sandi Baru <span className="text-destructive">*</span> Kata Sandi Baru{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<PasswordInput <PasswordInput
@ -80,7 +88,10 @@ export default function Security(props: Props) {
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="password_confirmation"> <Label htmlFor="password_confirmation">
Konfirmasi Kata Sandi <span className="text-destructive">*</span> Konfirmasi Kata Sandi{' '}
<span className="text-destructive">
*
</span>
</Label> </Label>
<PasswordInput <PasswordInput
@ -93,7 +104,9 @@ export default function Security(props: Props) {
/> />
<InputError <InputError
message={errors.password_confirmation} message={
errors.password_confirmation
}
/> />
</div> </div>
</CardContent> </CardContent>

View File

@ -5,9 +5,14 @@ declare module 'virtual:pwa-register' {
immediate?: boolean; immediate?: boolean;
onNeedRefresh?: () => void; onNeedRefresh?: () => void;
onOfflineReady?: () => void; onOfflineReady?: () => void;
onRegisteredSW?: (swUrl: string, registration: ServiceWorkerRegistration | undefined) => void; onRegisteredSW?: (
swUrl: string,
registration: ServiceWorkerRegistration | undefined,
) => void;
onRegisterError?: (error: Error) => void; onRegisterError?: (error: Error) => void;
} }
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => void; export function registerSW(
options?: RegisterSWOptions,
): (reloadPage?: boolean) => void;
} }

View File

@ -1,6 +1,5 @@
<?php <?php
use App\Enums\PriceType;
use App\Models\Category; use App\Models\Category;
use App\Models\Product; use App\Models\Product;
use App\Models\ProductPrice; use App\Models\ProductPrice;
@ -387,7 +386,7 @@ function allPriceTypes(): array
'reject_stock' => 2, 'reject_stock' => 2,
'retail_stock' => 4, 'retail_stock' => 4,
'photo_key' => 'product-variant/v2.jpg', 'photo_key' => 'product-variant/v2.jpg',
'prices' => array_map(fn($p) => ['type' => $p['type'], 'price' => $p['price'] * 2], allPriceTypes()), 'prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => $p['price'] * 2], allPriceTypes()),
], ],
], ],
])); ]));
@ -409,7 +408,7 @@ function allPriceTypes(): array
'reject_stock' => 0, 'reject_stock' => 0,
'retail_stock' => 0, 'retail_stock' => 0,
'photo_key' => 'product-variant/free.jpg', 'photo_key' => 'product-variant/free.jpg',
'prices' => array_map(fn($p) => ['type' => $p['type'], 'price' => 0], allPriceTypes()), 'prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => 0], allPriceTypes()),
]], ]],
])); ]));
@ -431,7 +430,7 @@ function allPriceTypes(): array
'reject_stock' => 0, 'reject_stock' => 0,
'retail_stock' => 0, 'retail_stock' => 0,
'photo_key' => 'product-variant/expensive.jpg', 'photo_key' => 'product-variant/expensive.jpg',
'prices' => array_map(fn($p) => ['type' => $p['type'], 'price' => 999999999], allPriceTypes()), 'prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => 999999999], allPriceTypes()),
]], ]],
])); ]));
@ -1654,7 +1653,7 @@ function allPriceTypes(): array
$this->actingAs($user); $this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload([ $response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload([
'shared_prices' => array_map(fn($p) => ['type' => $p['type'], 'price' => '15.000'], allPriceTypes()), 'shared_prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => '15.000'], allPriceTypes()),
])); ]));
$response->assertSessionHasErrors('shared_prices.0.price'); $response->assertSessionHasErrors('shared_prices.0.price');
@ -1665,7 +1664,7 @@ function allPriceTypes(): array
$this->actingAs($user); $this->actingAs($user);
$response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload([ $response = $this->post(route('admin.master.products.store'), makeValidSharedPricePayload([
'shared_prices' => array_map(fn($p) => ['type' => $p['type'], 'price' => '1.250.000'], allPriceTypes()), 'shared_prices' => array_map(fn ($p) => ['type' => $p['type'], 'price' => '1.250.000'], allPriceTypes()),
])); ]));
$response->assertSessionHasErrors('shared_prices.0.price'); $response->assertSessionHasErrors('shared_prices.0.price');