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:
parent
2a3e70b78d
commit
23cc327190
@ -3,8 +3,8 @@
|
||||
namespace App\Http\Controllers\Admin\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Http\Requests\Admin\Finance\CashTransactionRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\CashTransaction;
|
||||
use App\Services\Admin\Finance\CashAccountService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
namespace App\Http\Controllers\Admin\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\EmployeeAdvance;
|
||||
use App\Services\Admin\Finance\EmployeeAdvanceService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
namespace App\Http\Controllers\Admin\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Http\Requests\Admin\Finance\ExpenseRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\Expense;
|
||||
use App\Services\Admin\Finance\ExpenseService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
@ -16,7 +16,7 @@ public function __construct(
|
||||
public function pay(Payroll $payroll): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn() => $this->service->pay($payroll),
|
||||
fn () => $this->service->pay($payroll),
|
||||
'Gaji berhasil dibayar.',
|
||||
'admin.finance.payroll-periods.show',
|
||||
parameters: ['payroll_period' => $payroll->payroll_period_id]
|
||||
@ -26,7 +26,7 @@ public function pay(Payroll $payroll): RedirectResponse
|
||||
public function cancel(Payroll $payroll): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn() => $this->service->cancel($payroll),
|
||||
fn () => $this->service->cancel($payroll),
|
||||
'Gaji berhasil dibatalkan.',
|
||||
'admin.finance.payroll-periods.show',
|
||||
parameters: ['payroll_period' => $payroll->payroll_period_id]
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
namespace App\Http\Controllers\Admin\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Http\Requests\Admin\HR\EmployeeRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\HR\EmployeeService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
namespace App\Http\Controllers\Admin\HR;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Http\Requests\Admin\HR\LeaveRequestRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\LeaveRequest;
|
||||
use App\Services\Admin\HR\LeaveRequestService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
namespace App\Http\Controllers\Admin\Master;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Http\Requests\Admin\Master\CategoryRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\Category;
|
||||
use App\Services\Admin\Master\CategoryService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
namespace App\Http\Controllers\Admin\Master;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Http\Requests\Admin\Master\CustomerRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Admin\Master\CustomerService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
@ -2,10 +2,9 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin\Master;
|
||||
|
||||
use App\Enums\ProductStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Http\Requests\Admin\Master\ProductRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\Product;
|
||||
use App\Services\Admin\Master\CategoryService;
|
||||
use App\Services\Admin\Master\ProductService;
|
||||
@ -41,7 +40,7 @@ public function create(): Response
|
||||
public function store(ProductRequest $request): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn() => $this->service->create($request->validated()),
|
||||
fn () => $this->service->create($request->validated()),
|
||||
'Produk berhasil ditambahkan.',
|
||||
'admin.master.products.index',
|
||||
'admin.master.products.create'
|
||||
@ -59,7 +58,7 @@ public function edit(Product $product): Response
|
||||
public function update(ProductRequest $request, Product $product): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn() => $this->service->update($product, $request->validated()),
|
||||
fn () => $this->service->update($product, $request->validated()),
|
||||
'Produk berhasil diperbarui.',
|
||||
'admin.master.products.index',
|
||||
'admin.master.products.edit',
|
||||
@ -70,7 +69,7 @@ public function update(ProductRequest $request, Product $product): RedirectRespo
|
||||
public function destroy(Product $product): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn() => $this->service->delete($product),
|
||||
fn () => $this->service->delete($product),
|
||||
'Produk berhasil dihapus.',
|
||||
'admin.master.products.index'
|
||||
);
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
namespace App\Http\Controllers\Admin\Master;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Http\Requests\Admin\Master\SupplierRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\Admin\Master\SupplierService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Http\Requests\Admin\RoleRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Services\Admin\Settings\RoleService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
|
||||
@ -13,13 +13,16 @@ protected function handleAction(callable $action, string $successMessage, string
|
||||
try {
|
||||
$action();
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => $successMessage]);
|
||||
|
||||
return to_route($redirectRoute, $parameters);
|
||||
} catch (ValidationException $e) {
|
||||
$firstError = collect($e->errors())->flatten()->first();
|
||||
Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']);
|
||||
|
||||
return to_route($errorRoute ?? $redirectRoute, $parameters);
|
||||
} catch (\Exception $e) {
|
||||
Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]);
|
||||
|
||||
return to_route($errorRoute ?? $redirectRoute, $parameters);
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,7 +65,7 @@ public function rules(): array
|
||||
'variants.*.reject_stock' => ['required', 'integer', 'min:0'],
|
||||
'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
|
||||
'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.*.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'],
|
||||
|
||||
@ -11,7 +11,6 @@
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['type_label'])]
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
#[Guarded(['id'])]
|
||||
class ProductVariant extends Model implements HasMedia
|
||||
{
|
||||
use HasFactory, SoftDeletes, InteractsWithMedia;
|
||||
use HasFactory, InteractsWithMedia, SoftDeletes;
|
||||
|
||||
public function orderItems(): HasMany
|
||||
{
|
||||
|
||||
@ -2,10 +2,9 @@
|
||||
|
||||
namespace App\Services\Admin\Master;
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\ProductPrice;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
@ -123,6 +122,7 @@ public function getForEdit(Product $product): array
|
||||
|
||||
$variants = $product->productVariants->map(function (ProductVariant $variant) {
|
||||
$media = $variant->getMedia('photos')->first();
|
||||
|
||||
return [
|
||||
'id' => $variant->id,
|
||||
'name' => $variant->name,
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import 'tw-animate-css';
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/inter";
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn/tailwind.css';
|
||||
@import '@fontsource-variable/inter';
|
||||
|
||||
@source '../views';
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@ -65,137 +65,74 @@ @theme {
|
||||
}
|
||||
|
||||
:root {
|
||||
--background:
|
||||
oklch(1 0 0);
|
||||
--foreground:
|
||||
oklch(0.145 0 0);
|
||||
--card:
|
||||
oklch(1 0 0);
|
||||
--card-foreground:
|
||||
oklch(0.145 0 0);
|
||||
--popover:
|
||||
oklch(1 0 0);
|
||||
--popover-foreground:
|
||||
oklch(0.145 0 0);
|
||||
--primary:
|
||||
oklch(0.555 0.163 48.998);
|
||||
--primary-foreground:
|
||||
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);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.555 0.163 48.998);
|
||||
--primary-foreground: 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);
|
||||
--border:
|
||||
oklch(0.922 0 0);
|
||||
--input:
|
||||
oklch(0.922 0 0);
|
||||
--ring:
|
||||
oklch(0.708 0 0);
|
||||
--chart-1:
|
||||
oklch(0.879 0.169 91.605);
|
||||
--chart-2:
|
||||
oklch(0.769 0.188 70.08);
|
||||
--chart-3:
|
||||
oklch(0.666 0.179 58.318);
|
||||
--chart-4:
|
||||
oklch(0.555 0.163 48.998);
|
||||
--chart-5:
|
||||
oklch(0.473 0.137 46.201);
|
||||
--radius:
|
||||
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);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.879 0.169 91.605);
|
||||
--chart-2: oklch(0.769 0.188 70.08);
|
||||
--chart-3: oklch(0.666 0.179 58.318);
|
||||
--chart-4: oklch(0.555 0.163 48.998);
|
||||
--chart-5: oklch(0.473 0.137 46.201);
|
||||
--radius: 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 {
|
||||
--background:
|
||||
oklch(0.145 0 0);
|
||||
--foreground:
|
||||
oklch(0.985 0 0);
|
||||
--card:
|
||||
oklch(0.205 0 0);
|
||||
--card-foreground:
|
||||
oklch(0.985 0 0);
|
||||
--popover:
|
||||
oklch(0.205 0 0);
|
||||
--popover-foreground:
|
||||
oklch(0.985 0 0);
|
||||
--primary:
|
||||
oklch(0.473 0.137 46.201);
|
||||
--primary-foreground:
|
||||
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);
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.473 0.137 46.201);
|
||||
--primary-foreground: 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);
|
||||
--border:
|
||||
oklch(1 0 0 / 10%);
|
||||
--input:
|
||||
oklch(1 0 0 / 15%);
|
||||
--ring:
|
||||
oklch(0.556 0 0);
|
||||
--chart-1:
|
||||
oklch(0.879 0.169 91.605);
|
||||
--chart-2:
|
||||
oklch(0.769 0.188 70.08);
|
||||
--chart-3:
|
||||
oklch(0.666 0.179 58.318);
|
||||
--chart-4:
|
||||
oklch(0.555 0.163 48.998);
|
||||
--chart-5:
|
||||
oklch(0.473 0.137 46.201);
|
||||
--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);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.879 0.169 91.605);
|
||||
--chart-2: oklch(0.769 0.188 70.08);
|
||||
--chart-3: oklch(0.666 0.179 58.318);
|
||||
--chart-4: oklch(0.555 0.163 48.998);
|
||||
--chart-5: oklch(0.473 0.137 46.201);
|
||||
--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 {
|
||||
@ -206,90 +143,50 @@ @layer base {
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-heading:
|
||||
var(--font-sans);
|
||||
--font-sans:
|
||||
'Inter Variable', sans-serif;
|
||||
--color-sidebar-ring:
|
||||
var(--sidebar-ring);
|
||||
--color-sidebar-border:
|
||||
var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground:
|
||||
var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent:
|
||||
var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground:
|
||||
var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary:
|
||||
var(--sidebar-primary);
|
||||
--color-sidebar-foreground:
|
||||
var(--sidebar-foreground);
|
||||
--color-sidebar:
|
||||
var(--sidebar);
|
||||
--color-chart-5:
|
||||
var(--chart-5);
|
||||
--color-chart-4:
|
||||
var(--chart-4);
|
||||
--color-chart-3:
|
||||
var(--chart-3);
|
||||
--color-chart-2:
|
||||
var(--chart-2);
|
||||
--color-chart-1:
|
||||
var(--chart-1);
|
||||
--color-ring:
|
||||
var(--ring);
|
||||
--color-input:
|
||||
var(--input);
|
||||
--color-border:
|
||||
var(--border);
|
||||
--color-destructive:
|
||||
var(--destructive);
|
||||
--color-accent-foreground:
|
||||
var(--accent-foreground);
|
||||
--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);
|
||||
}
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Inter Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--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);
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@ import type { ImgHTMLAttributes } from 'react';
|
||||
|
||||
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} />;
|
||||
}
|
||||
|
||||
@ -106,7 +106,10 @@ function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild tooltip={{ children: item.title }}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={{ children: item.title }}
|
||||
>
|
||||
<Link href={item.href} prefetch>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
@ -151,7 +154,11 @@ export function AppSidebar() {
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={isCurrentUrl('/dashboard')} tooltip={{ children: dasborItem.title }}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isCurrentUrl('/dashboard')}
|
||||
tooltip={{ children: dasborItem.title }}
|
||||
>
|
||||
<Link href={dasborItem.href} prefetch>
|
||||
<dasborItem.icon />
|
||||
<span>{dasborItem.title}</span>
|
||||
@ -164,7 +171,10 @@ export function AppSidebar() {
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip={{ children: analisaItem.title }}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={{ children: analisaItem.title }}
|
||||
>
|
||||
<Link href={analisaItem.href} prefetch>
|
||||
<analisaItem.icon />
|
||||
<span>{analisaItem.title}</span>
|
||||
|
||||
@ -25,7 +25,9 @@ export function CameraCapture({ onCapture, onClose }: CameraCaptureProps) {
|
||||
}
|
||||
setError(null);
|
||||
} 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 ? (
|
||||
<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>
|
||||
</div>
|
||||
) : capturedImage ? (
|
||||
<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">
|
||||
<Button variant="outline" className="flex-1" onClick={retake}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={retake}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Ulangi
|
||||
</Button>
|
||||
|
||||
@ -4,7 +4,7 @@ import {
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
@ -17,9 +17,15 @@ import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
useReactTable
|
||||
useReactTable,
|
||||
} 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 { Button } from '@/components/ui/button';
|
||||
@ -167,8 +173,8 @@ export function DataTable<TData, TValue>({
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [expanded, setExpanded] = React.useState<ExpandedState>(() => {
|
||||
if (!defaultExpanded || !data.length) {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const initial: Record<string, boolean> = {};
|
||||
data.forEach((item, index) => {
|
||||
@ -194,17 +200,17 @@ return {};
|
||||
|
||||
const visibleColumns = isSortable
|
||||
? [
|
||||
{
|
||||
id: 'drag',
|
||||
header: '',
|
||||
cell: () => <DragHandleTrigger />,
|
||||
meta: {
|
||||
className: 'w-[40px]',
|
||||
headerClassName: 'w-[40px]',
|
||||
},
|
||||
} as ColumnDef<TData, TValue>,
|
||||
...columns,
|
||||
]
|
||||
{
|
||||
id: 'drag',
|
||||
header: '',
|
||||
cell: () => <DragHandleTrigger />,
|
||||
meta: {
|
||||
className: 'w-[40px]',
|
||||
headerClassName: 'w-[40px]',
|
||||
},
|
||||
} as ColumnDef<TData, TValue>,
|
||||
...columns,
|
||||
]
|
||||
: columns;
|
||||
|
||||
const sensors = useSensors(
|
||||
@ -261,8 +267,8 @@ return {};
|
||||
|
||||
function handleSort(columnId: string) {
|
||||
if (!onSortChange) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const newDirection =
|
||||
currentSort?.column === columnId && currentSort?.direction === 'asc'
|
||||
@ -282,25 +288,31 @@ return;
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={localSearch}
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
onChange={(event) =>
|
||||
handleSearchChange(event.target.value)
|
||||
}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
)}
|
||||
{toolbar}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{isServerMode && onPerPageChange && (
|
||||
<Select
|
||||
value={String(pagination?.per_page ?? 25)}
|
||||
onValueChange={(value) => onPerPageChange(Number(value))}
|
||||
onValueChange={(value) =>
|
||||
onPerPageChange(Number(value))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="25">25</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="999999">Semua</SelectItem>
|
||||
<SelectItem value="25">25</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="999999">
|
||||
Semua
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
@ -320,18 +332,18 @@ return;
|
||||
(
|
||||
header.column.columnDef
|
||||
.meta as {
|
||||
headerClassName?: string;
|
||||
}
|
||||
headerClassName?: string;
|
||||
}
|
||||
)?.headerClassName
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
@ -374,8 +386,8 @@ return;
|
||||
.column
|
||||
.columnDef
|
||||
.meta as {
|
||||
className?: string;
|
||||
}
|
||||
className?: string;
|
||||
}
|
||||
)
|
||||
?.className
|
||||
}
|
||||
@ -394,55 +406,58 @@ return;
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
table
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<React.Fragment key={row.id}>
|
||||
<TableRow
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
'selected'
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.meta as {
|
||||
className?: string;
|
||||
}
|
||||
)?.className
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<React.Fragment key={row.id}>
|
||||
<TableRow
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
'selected'
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{renderSubRow && row.getIsExpanded() && (
|
||||
.meta as {
|
||||
className?: string;
|
||||
}
|
||||
)?.className
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{renderSubRow &&
|
||||
row.getIsExpanded() && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={visibleColumns.length}
|
||||
colSpan={
|
||||
visibleColumns.length
|
||||
}
|
||||
className="bg-muted/50 p-0"
|
||||
>
|
||||
<div className="p-4">
|
||||
{renderSubRow(row, localSearch)}
|
||||
{renderSubRow(
|
||||
row,
|
||||
localSearch,
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
</React.Fragment>
|
||||
))
|
||||
)
|
||||
) : (
|
||||
<TableRow>
|
||||
@ -530,7 +545,9 @@ return;
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
onClick={() =>
|
||||
table.setPageIndex(table.getPageCount() - 1)
|
||||
}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
|
||||
@ -1,33 +1,33 @@
|
||||
import * as React from "react"
|
||||
import { format } from "date-fns"
|
||||
import { id } from "date-fns/locale"
|
||||
import { CalendarIcon } from "lucide-react"
|
||||
import * as React from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { id } from 'date-fns/locale';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
} from '@/components/ui/popover';
|
||||
|
||||
interface DatePickerProps {
|
||||
value?: Date | string | null
|
||||
onChange?: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
name?: string
|
||||
id?: string
|
||||
min?: Date
|
||||
max?: Date
|
||||
value?: Date | string | null;
|
||||
onChange?: (date: Date | undefined) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
min?: Date;
|
||||
max?: Date;
|
||||
}
|
||||
|
||||
function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Pilih tanggal",
|
||||
placeholder = 'Pilih tanggal',
|
||||
disabled = false,
|
||||
className,
|
||||
name,
|
||||
@ -35,18 +35,18 @@ function DatePicker({
|
||||
min,
|
||||
max,
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const date = React.useMemo(() => {
|
||||
if (!value) return undefined
|
||||
if (value instanceof Date) return value
|
||||
return new Date(value)
|
||||
}, [value])
|
||||
if (!value) return undefined;
|
||||
if (value instanceof Date) return value;
|
||||
return new Date(value);
|
||||
}, [value]);
|
||||
|
||||
const formattedDate = React.useMemo(() => {
|
||||
if (!date) return ""
|
||||
return format(date, "dd MMM yyyy", { locale: id })
|
||||
}, [date])
|
||||
if (!date) return '';
|
||||
return format(date, 'dd MMM yyyy', { locale: id });
|
||||
}, [date]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@ -56,9 +56,9 @@ function DatePicker({
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!date && "text-muted-foreground",
|
||||
className
|
||||
'w-full justify-start text-left font-normal',
|
||||
!date && 'text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
@ -70,22 +70,26 @@ function DatePicker({
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={(selectedDate) => {
|
||||
onChange?.(selectedDate)
|
||||
setOpen(false)
|
||||
onChange?.(selectedDate);
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={(date) => {
|
||||
if (min && date < min) return true
|
||||
if (max && date > max) return true
|
||||
return false
|
||||
if (min && date < min) return true;
|
||||
if (max && date > max) return true;
|
||||
return false;
|
||||
}}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
{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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { DatePicker }
|
||||
export { DatePicker };
|
||||
|
||||
@ -49,7 +49,12 @@ function acceptToLabels(accept: string): string[] {
|
||||
|
||||
return accept
|
||||
.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);
|
||||
}
|
||||
|
||||
@ -60,7 +65,16 @@ function formatMaxSize(bytes: number): string {
|
||||
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 uploadId = useId();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@ -96,13 +110,15 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
|
||||
onChange(key);
|
||||
onFileMeta?.({ size: file.size, type: file.type });
|
||||
} catch (err) {
|
||||
const message = err instanceof UploadError ? err.message : 'Gagal mengunggah file.';
|
||||
const message =
|
||||
err instanceof UploadError
|
||||
? err.message
|
||||
: 'Gagal mengunggah file.';
|
||||
setError(message);
|
||||
setPreview(null);
|
||||
setFileName(null);
|
||||
setFileSize(null);
|
||||
onFileMeta?.(null);
|
||||
|
||||
} finally {
|
||||
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 (
|
||||
<>
|
||||
@ -139,13 +161,21 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
|
||||
id={uploadId}
|
||||
/>
|
||||
|
||||
<Attachment state={state} orientation="horizontal" className='w-full'>
|
||||
<Attachment
|
||||
state={state}
|
||||
orientation="horizontal"
|
||||
className="w-full"
|
||||
>
|
||||
<AttachmentTrigger
|
||||
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 ? (
|
||||
<img src={preview} alt={fileName ?? 'Preview'} />
|
||||
) : existingUrl && value ? (
|
||||
@ -162,18 +192,24 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
|
||||
<>
|
||||
<AttachmentTitle>{fileName}</AttachmentTitle>
|
||||
<AttachmentDescription>
|
||||
{fileSize ? formatFileSize(fileSize) : 'Terupload'}
|
||||
{fileSize
|
||||
? formatFileSize(fileSize)
|
||||
: 'Terupload'}
|
||||
</AttachmentDescription>
|
||||
</>
|
||||
) : uploading ? (
|
||||
<>
|
||||
<AttachmentTitle>Mengunggah...</AttachmentTitle>
|
||||
<AttachmentDescription>Memproses file</AttachmentDescription>
|
||||
<AttachmentDescription>
|
||||
Memproses file
|
||||
</AttachmentDescription>
|
||||
</>
|
||||
) : error ? (
|
||||
<>
|
||||
<AttachmentTitle>Gagal</AttachmentTitle>
|
||||
<AttachmentDescription>{error}</AttachmentDescription>
|
||||
<AttachmentDescription>
|
||||
{error}
|
||||
</AttachmentDescription>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@ -187,7 +223,10 @@ export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image
|
||||
|
||||
{value && (
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label="Hapus file" onClick={handleRemove}>
|
||||
<AttachmentAction
|
||||
aria-label="Hapus file"
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<X />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
type ImagePreviewModalProps = {
|
||||
open: boolean;
|
||||
@ -8,7 +13,13 @@ type ImagePreviewModalProps = {
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export function ImagePreviewModal({ open, onOpenChange, src, title, alt = 'Preview' }: ImagePreviewModalProps) {
|
||||
export function ImagePreviewModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
src,
|
||||
title,
|
||||
alt = 'Preview',
|
||||
}: ImagePreviewModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent showCloseButton>
|
||||
@ -21,7 +32,7 @@ export function ImagePreviewModal({ open, onOpenChange, src, title, alt = 'Previ
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="w-full rounded-lg object-contain max-h-[80vh]"
|
||||
className="max-h-[80vh] w-full rounded-lg object-contain"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@ -9,7 +9,12 @@ interface LocationMapProps {
|
||||
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 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.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
}).addTo(map);
|
||||
|
||||
const icon = L.divIcon({
|
||||
@ -46,5 +52,11 @@ export function LocationMap({ latitude, longitude, height = '250px', zoom = 15 }
|
||||
};
|
||||
}, [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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -19,17 +19,14 @@ export function NavUser() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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"
|
||||
>
|
||||
<UserInfo user={auth.user} />
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-56 rounded-lg"
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenuContent className="w-56 rounded-lg" align="end">
|
||||
<UserMenuContent user={auth.user} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@ -12,7 +12,8 @@ export function PWAUpdateToast() {
|
||||
|
||||
const handleUpdateAvailable = () => {
|
||||
toast.info('Update tersedia!', {
|
||||
description: 'Versi baru aplikasi telah tersedia. Klik untuk memperbarui.',
|
||||
description:
|
||||
'Versi baru aplikasi telah tersedia. Klik untuk memperbarui.',
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: 'Update',
|
||||
@ -28,7 +29,10 @@ export function PWAUpdateToast() {
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('sw-offline-ready', handleOfflineReady);
|
||||
window.removeEventListener('sw-update-available', handleUpdateAvailable);
|
||||
window.removeEventListener(
|
||||
'sw-update-available',
|
||||
handleUpdateAvailable,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@ -40,7 +40,9 @@ export function RupiahInput({
|
||||
className,
|
||||
}: RupiahInputProps) {
|
||||
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);
|
||||
|
||||
if (isControlled) {
|
||||
|
||||
@ -18,27 +18,54 @@ export function useFileUpload() {
|
||||
preview: null,
|
||||
});
|
||||
|
||||
const upload = useCallback(async (file: File, folder?: string): Promise<string | null> => {
|
||||
setState({ uploading: true, progress: 0, error: null, key: null, preview: null });
|
||||
const upload = useCallback(
|
||||
async (file: File, folder?: string): Promise<string | null> => {
|
||||
setState({
|
||||
uploading: true,
|
||||
progress: 0,
|
||||
error: null,
|
||||
key: null,
|
||||
preview: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const preview = URL.createObjectURL(file);
|
||||
setState((prev) => ({ ...prev, preview, progress: 30 }));
|
||||
try {
|
||||
const preview = URL.createObjectURL(file);
|
||||
setState((prev) => ({ ...prev, preview, progress: 30 }));
|
||||
|
||||
const key = await uploadFile(file, folder);
|
||||
setState((prev) => ({ ...prev, key, uploading: false, progress: 100 }));
|
||||
const key = await uploadFile(file, folder);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
key,
|
||||
uploading: false,
|
||||
progress: 100,
|
||||
}));
|
||||
|
||||
return key;
|
||||
} catch (err) {
|
||||
const message = err instanceof UploadError ? err.message : 'Terjadi kesalahan saat mengunggah file.';
|
||||
setState((prev) => ({ ...prev, error: message, uploading: false }));
|
||||
return key;
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof UploadError
|
||||
? err.message
|
||||
: 'Terjadi kesalahan saat mengunggah file.';
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: message,
|
||||
uploading: false,
|
||||
}));
|
||||
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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) => {
|
||||
|
||||
@ -9,8 +9,8 @@ function getInitial(name: string): string {
|
||||
export function useInitials(): GetInitialsFn {
|
||||
return useCallback((fullName: string): string => {
|
||||
if (!fullName) {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const names = fullName.trim().split(/\s+/u).filter(Boolean);
|
||||
|
||||
|
||||
@ -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() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(
|
||||
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
|
||||
);
|
||||
const onChange = () => {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -11,7 +11,10 @@ export default function AppSidebarLayout({
|
||||
return (
|
||||
<AppShell variant="sidebar">
|
||||
<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} />
|
||||
{children}
|
||||
</AppContent>
|
||||
|
||||
@ -26,7 +26,11 @@ export default function AuthCardLayout({
|
||||
className="flex items-center gap-2 self-center font-medium"
|
||||
>
|
||||
<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>
|
||||
</Link>
|
||||
|
||||
|
||||
@ -73,9 +73,7 @@ export default function SettingsLayout({ children }: PropsWithChildren) {
|
||||
|
||||
<Separator className="my-6 lg:hidden" />
|
||||
|
||||
<section className="flex-1 space-y-12">
|
||||
{children}
|
||||
</section>
|
||||
<section className="flex-1 space-y-12">{children}</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -15,7 +15,12 @@ export class UploadError extends Error {
|
||||
}
|
||||
|
||||
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(
|
||||
@ -31,7 +36,11 @@ export async function requestPresignedUrl(
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'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) {
|
||||
@ -59,14 +68,20 @@ export async function uploadToS3(uploadUrl: string, file: File): Promise<void> {
|
||||
|
||||
export async function uploadFile(file: File, folder?: string): Promise<string> {
|
||||
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) {
|
||||
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);
|
||||
|
||||
return key;
|
||||
|
||||
@ -30,9 +30,7 @@ export function createCashAccountColumns(
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
<span className="block text-center">{row.index + 1}</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
@ -46,9 +44,7 @@ export function createCashAccountColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Nama</span>
|
||||
@ -68,9 +64,7 @@ export function createCashAccountColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Saldo</span>
|
||||
@ -101,16 +95,12 @@ export function createCashAccountColumns(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleEdit(cashAccount)
|
||||
}
|
||||
onClick={() => handleEdit(cashAccount)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
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 { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
@ -18,11 +24,28 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { 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 {
|
||||
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 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 [withdrawalOpen, setWithdrawalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<CashTransaction | null>(null);
|
||||
const [deleting, setDeleting] = useState<CashTransaction | null>(null);
|
||||
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 [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 [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 [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 [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' });
|
||||
const [sort, setSort] = useState<SortState>({
|
||||
column: 'created_at',
|
||||
direction: 'desc',
|
||||
});
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: transactions.current_page,
|
||||
@ -86,24 +129,32 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
||||
newFilters[key as keyof typeof newFilters] = value;
|
||||
}
|
||||
|
||||
router.get(cashAccountIndex(), {
|
||||
...newFilters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
cashAccountIndex(),
|
||||
{
|
||||
...newFilters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
router.get(cashAccountIndex(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
cashAccountIndex(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
setFilterOpen(false);
|
||||
}
|
||||
|
||||
@ -118,49 +169,68 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(cashAccountIndex(), {
|
||||
...filters,
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
cashAccountIndex(),
|
||||
{
|
||||
...filters,
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(cashAccountIndex(), {
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
cashAccountIndex(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
router.get(cashAccountIndex(), {
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
}, [pagination.per_page, sort, filters]);
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
cashAccountIndex(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page, sort, filters],
|
||||
);
|
||||
|
||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||
setSort({ column, direction });
|
||||
router.get(cashAccountIndex(), {
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
cashAccountIndex(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const columns = createTransactionColumns({
|
||||
@ -207,7 +277,9 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
||||
</label>
|
||||
<Select
|
||||
value={filters.type ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('type', value)}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('type', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Tipe" />
|
||||
@ -215,9 +287,15 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Tipe</SelectItem>
|
||||
<SelectItem value="deposit">Deposit</SelectItem>
|
||||
<SelectItem value="withdrawal">Withdrawal</SelectItem>
|
||||
<SelectItem value="expense">Pengeluaran</SelectItem>
|
||||
<SelectItem value="transfer">Transfer</SelectItem>
|
||||
<SelectItem value="withdrawal">
|
||||
Withdrawal
|
||||
</SelectItem>
|
||||
<SelectItem value="expense">
|
||||
Pengeluaran
|
||||
</SelectItem>
|
||||
<SelectItem value="transfer">
|
||||
Transfer
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@ -238,11 +316,17 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
||||
</h2>
|
||||
</div>
|
||||
<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" />
|
||||
Deposit
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setWithdrawalOpen(true)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setWithdrawalOpen(true)}
|
||||
>
|
||||
<ArrowUpFromLine className="h-4 w-4" />
|
||||
Withdrawal
|
||||
</Button>
|
||||
@ -279,20 +363,27 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
||||
toolbar={filterToolbar}
|
||||
/>
|
||||
|
||||
<Dialog open={depositOpen} onOpenChange={(open) => {
|
||||
setDepositOpen(open);
|
||||
<Dialog
|
||||
open={depositOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDepositOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setDepositReceiptKey(null);
|
||||
setDepositFileMeta(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<Form action={deposit()} resetOnSuccess onSuccess={() => {
|
||||
setDepositOpen(false);
|
||||
if (!open) {
|
||||
setDepositReceiptKey(null);
|
||||
setDepositFileMeta(null);
|
||||
}}>
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<Form
|
||||
action={deposit()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setDepositOpen(false);
|
||||
setDepositReceiptKey(null);
|
||||
setDepositFileMeta(null);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<>
|
||||
<DialogHeader>
|
||||
@ -301,46 +392,95 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah <span className="text-destructive">*</span>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput name="amount" min={1} />
|
||||
<InputError message={errors.amount} />
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.amount}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Keterangan <span className="text-destructive">*</span>
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
<InputError
|
||||
message={errors.description}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Bukti{' '} <span className="text-destructive">*</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 ?? ''} />
|
||||
<Label>
|
||||
Bukti{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</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
|
||||
value={depositReceiptKey}
|
||||
onChange={setDepositReceiptKey}
|
||||
folder="cash-transaction"
|
||||
onUploadingChange={setDepositUploading}
|
||||
onUploadingChange={
|
||||
setDepositUploading
|
||||
}
|
||||
onFileMeta={setDepositFileMeta}
|
||||
/>
|
||||
<InputError message={errors.receipt_key} />
|
||||
<InputError
|
||||
message={errors.receipt_key}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDepositOpen(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setDepositOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" disabled={processing || depositUploading}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
processing || depositUploading
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: depositUploading
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
@ -349,20 +489,27 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={withdrawalOpen} onOpenChange={(open) => {
|
||||
setWithdrawalOpen(open);
|
||||
<Dialog
|
||||
open={withdrawalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setWithdrawalOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setWithdrawalReceiptKey(null);
|
||||
setWithdrawalFileMeta(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<Form action={withdrawal()} resetOnSuccess onSuccess={() => {
|
||||
setWithdrawalOpen(false);
|
||||
if (!open) {
|
||||
setWithdrawalReceiptKey(null);
|
||||
setWithdrawalFileMeta(null);
|
||||
}}>
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<Form
|
||||
action={withdrawal()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setWithdrawalOpen(false);
|
||||
setWithdrawalReceiptKey(null);
|
||||
setWithdrawalFileMeta(null);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<>
|
||||
<DialogHeader>
|
||||
@ -371,46 +518,104 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah <span className="text-destructive">*</span>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput name="amount" min={1} />
|
||||
<InputError message={errors.amount} />
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.amount}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Keterangan <span className="text-destructive">*</span>
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
<InputError
|
||||
message={errors.description}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Bukti{' '} <span className="text-destructive">*</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 ?? ''} />
|
||||
<Label>
|
||||
Bukti{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</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
|
||||
value={withdrawalReceiptKey}
|
||||
onChange={setWithdrawalReceiptKey}
|
||||
onChange={
|
||||
setWithdrawalReceiptKey
|
||||
}
|
||||
folder="cash-transaction"
|
||||
onUploadingChange={setWithdrawalUploading}
|
||||
onFileMeta={setWithdrawalFileMeta}
|
||||
onUploadingChange={
|
||||
setWithdrawalUploading
|
||||
}
|
||||
onFileMeta={
|
||||
setWithdrawalFileMeta
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.receipt_key}
|
||||
/>
|
||||
<InputError message={errors.receipt_key} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setWithdrawalOpen(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setWithdrawalOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" disabled={processing || withdrawalUploading}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
processing ||
|
||||
withdrawalUploading
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: withdrawalUploading
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
@ -431,61 +636,121 @@ export default function CashAccountIndex({ cashAccount, transactions, filters }:
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form action={updateTransaction(editing.id)} resetOnSuccess onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditReceiptKey(null);
|
||||
setEditFileMeta(null);
|
||||
}}>
|
||||
<Form
|
||||
action={updateTransaction(editing.id)}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditReceiptKey(null);
|
||||
setEditFileMeta(null);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Transaksi</DialogTitle>
|
||||
<DialogTitle>
|
||||
Edit Transaksi
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah <span className="text-destructive">*</span>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput name="amount" defaultValue={editing.amount} min={1} />
|
||||
<InputError message={errors.amount} />
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
defaultValue={
|
||||
editing.amount
|
||||
}
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.amount}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Keterangan <span className="text-destructive">*</span>
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
defaultValue={editing.description}
|
||||
defaultValue={
|
||||
editing.description
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.description}
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Bukti{' '} <span className="text-destructive">*</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 ?? ''} />
|
||||
<Label>
|
||||
Bukti{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</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
|
||||
value={editReceiptKey}
|
||||
onChange={setEditReceiptKey}
|
||||
folder="cash-transaction"
|
||||
onUploadingChange={setEditUploading}
|
||||
existingUrl={editing.receipt_url}
|
||||
onUploadingChange={
|
||||
setEditUploading
|
||||
}
|
||||
existingUrl={
|
||||
editing.receipt_url
|
||||
}
|
||||
onFileMeta={setEditFileMeta}
|
||||
/>
|
||||
<InputError message={errors.receipt_key} />
|
||||
<InputError
|
||||
message={errors.receipt_key}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditing(null)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditing(null)}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" disabled={processing || editUploading}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
processing || editUploading
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: editUploading
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
||||
@ -33,14 +33,18 @@ export type CashTransaction = {
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}) + ' ' + date.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return (
|
||||
date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
@ -105,9 +109,7 @@ export function createTransactionColumns(
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
<span className="block text-center">{row.index + 1}</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
@ -121,9 +123,7 @@ export function createTransactionColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Tanggal</span>
|
||||
@ -142,8 +142,14 @@ export function createTransactionColumns(
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{getTypeLabel(transaction.type)}</span>
|
||||
<span className="text-xs text-muted-foreground">{getReferenceLabel(transaction.reference?.type ?? '')}</span>
|
||||
<span className="font-medium">
|
||||
{getTypeLabel(transaction.type)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getReferenceLabel(
|
||||
transaction.reference?.type ?? '',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@ -155,9 +161,7 @@ export function createTransactionColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Jumlah</span>
|
||||
@ -169,8 +173,15 @@ export function createTransactionColumns(
|
||||
const isDeposit = transaction.type === 'deposit';
|
||||
|
||||
return (
|
||||
<span className={isDeposit ? 'text-green-600 font-medium' : 'text-red-600 font-medium'}>
|
||||
{isDeposit ? '+' : '-'} {formatCurrency(row.getValue('amount') as number)}
|
||||
<span
|
||||
className={
|
||||
isDeposit
|
||||
? 'font-medium text-green-600'
|
||||
: 'font-medium text-red-600'
|
||||
}
|
||||
>
|
||||
{isDeposit ? '+' : '-'}{' '}
|
||||
{formatCurrency(row.getValue('amount') as number)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@ -182,9 +193,7 @@ export function createTransactionColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Saldo Setelah</span>
|
||||
@ -192,14 +201,18 @@ export function createTransactionColumns(
|
||||
</Button>
|
||||
),
|
||||
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',
|
||||
header: () => <span>Keterangan</span>,
|
||||
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 <ReceiptPreview url={receiptUrl} title={row.original.description} />;
|
||||
return (
|
||||
<ReceiptPreview
|
||||
url={receiptUrl}
|
||||
title={row.original.description}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -233,7 +251,9 @@ export function createTransactionColumns(
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const transaction = row.original;
|
||||
const canEdit = transaction.type === 'deposit' || transaction.type === 'withdrawal';
|
||||
const canEdit =
|
||||
transaction.type === 'deposit' ||
|
||||
transaction.type === 'withdrawal';
|
||||
|
||||
if (!canEdit) {
|
||||
return <span className="block text-center">-</span>;
|
||||
@ -252,9 +272,7 @@ export function createTransactionColumns(
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
@ -262,7 +280,9 @@ export function createTransactionColumns(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteClick(transaction)}
|
||||
onClick={() =>
|
||||
handleDeleteClick(transaction)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@ -30,14 +36,18 @@ export type EmployeeAdvance = {
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}) + ' ' + date.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return (
|
||||
date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function formatShortDate(dateString: string): string {
|
||||
@ -100,9 +110,7 @@ export function createEmployeeAdvanceColumns(
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
<span className="block text-center">{row.index + 1}</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
@ -116,9 +124,7 @@ export function createEmployeeAdvanceColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Tanggal</span>
|
||||
@ -135,7 +141,11 @@ export function createEmployeeAdvanceColumns(
|
||||
cell: ({ row }) => {
|
||||
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"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Jumlah</span>
|
||||
@ -155,7 +163,7 @@ export function createEmployeeAdvanceColumns(
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-red-600 font-medium">
|
||||
<span className="font-medium text-red-600">
|
||||
- {formatCurrency(row.getValue('amount') as number)}
|
||||
</span>
|
||||
),
|
||||
@ -164,7 +172,9 @@ export function createEmployeeAdvanceColumns(
|
||||
accessorKey: 'description',
|
||||
header: () => <span>Keterangan</span>,
|
||||
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"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Jatuh Tempo</span>
|
||||
@ -184,7 +192,9 @@ export function createEmployeeAdvanceColumns(
|
||||
</Button>
|
||||
),
|
||||
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" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
|
||||
@ -17,7 +17,14 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 type { EmployeeAdvance } from './columns';
|
||||
|
||||
@ -38,9 +45,14 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
||||
const [approving, setApproving] = useState<EmployeeAdvance | null>(null);
|
||||
const [paying, setPaying] = useState<EmployeeAdvance | null>(null);
|
||||
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 [sort, setSort] = useState<SortState>({ column: 'created_at', direction: 'desc' });
|
||||
const [sort, setSort] = useState<SortState>({
|
||||
column: 'created_at',
|
||||
direction: 'desc',
|
||||
});
|
||||
|
||||
const pagination: PaginationState = {
|
||||
current_page: employeeAdvances.current_page,
|
||||
@ -72,9 +84,13 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.post(approve(approving.id), {}, {
|
||||
onSuccess: () => setApproving(null),
|
||||
});
|
||||
router.post(
|
||||
approve(approving.id),
|
||||
{},
|
||||
{
|
||||
onSuccess: () => setApproving(null),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handlePay() {
|
||||
@ -82,51 +98,74 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.post(pay(paying.id), {}, {
|
||||
onSuccess: () => setPaying(null),
|
||||
});
|
||||
router.post(
|
||||
pay(paying.id),
|
||||
{},
|
||||
{
|
||||
onSuccess: () => setPaying(null),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(employeeAdvanceIndex.url(), {
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
employeeAdvanceIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(employeeAdvanceIndex.url(), {
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
employeeAdvanceIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
router.get(employeeAdvanceIndex.url(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
}, [pagination.per_page, sort]);
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
employeeAdvanceIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page, sort],
|
||||
);
|
||||
|
||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||
setSort({ column, direction });
|
||||
router.get(employeeAdvanceIndex.url(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
employeeAdvanceIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const columns = createEmployeeAdvanceColumns({
|
||||
@ -147,13 +186,16 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
||||
Kasbon
|
||||
</h2>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setDueDate(undefined);
|
||||
}
|
||||
}}>
|
||||
if (!open) {
|
||||
setDueDate(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
@ -164,57 +206,98 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
||||
</button>
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
|
||||
<Form
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setCreateOpen(false)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Kasbon</DialogTitle>
|
||||
<DialogTitle>
|
||||
Tambah Kasbon
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '} <span className="text-destructive">*</span>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput name="amount" min={1} />
|
||||
<InputError message={errors.amount} />
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.amount}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">
|
||||
Keterangan{' '} <span className="text-destructive">*</span>
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
<InputError
|
||||
message={
|
||||
errors.description
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="due_date">
|
||||
Jatuh Tempo{' '} <span className="text-destructive">*</span>
|
||||
Jatuh Tempo{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</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
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
placeholder="Pilih jatuh tempo"
|
||||
min={new Date()}
|
||||
/>
|
||||
<InputError message={errors.due_date} />
|
||||
<InputError
|
||||
message={
|
||||
errors.due_date
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
onClick={() =>
|
||||
setCreateOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
@ -256,43 +339,95 @@ export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) {
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditingDueDate(undefined);
|
||||
}}>
|
||||
<Form
|
||||
action={update(editing.id)}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditingDueDate(undefined);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Kasbon</DialogTitle>
|
||||
<DialogTitle>
|
||||
Edit Kasbon
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Jumlah{' '} <span className="text-destructive">*</span></Label>
|
||||
<RupiahInput name="amount" defaultValue={editing.amount} min={1} />
|
||||
<InputError message={errors.amount} />
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
defaultValue={
|
||||
editing.amount
|
||||
}
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.amount}
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
id="edit-description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
defaultValue={editing.description}
|
||||
defaultValue={
|
||||
editing.description
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.description
|
||||
}
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-due_date">Jatuh Tempo{' '} <span className="text-destructive">*</span></Label>
|
||||
<input type="hidden" name="due_date" value={editingDueDate ? editingDueDate.toISOString().split('T')[0] : ''} />
|
||||
<Label htmlFor="edit-due_date">
|
||||
Jatuh Tempo{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="due_date"
|
||||
value={
|
||||
editingDueDate
|
||||
? editingDueDate
|
||||
.toISOString()
|
||||
.split(
|
||||
'T',
|
||||
)[0]
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<DatePicker
|
||||
value={editingDueDate}
|
||||
onChange={setEditingDueDate}
|
||||
onChange={
|
||||
setEditingDueDate
|
||||
}
|
||||
placeholder="Pilih jatuh tempo"
|
||||
min={new Date()}
|
||||
/>
|
||||
<InputError message={errors.due_date} />
|
||||
<InputError
|
||||
message={
|
||||
errors.due_date
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@ -28,14 +28,18 @@ export type Expense = {
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}) + ' ' + date.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return (
|
||||
date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
type CreateColumnsParams = {
|
||||
@ -78,9 +82,7 @@ export function createExpenseColumns(
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
<span className="block text-center">{row.index + 1}</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
@ -94,9 +96,7 @@ export function createExpenseColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Tanggal</span>
|
||||
@ -111,7 +111,9 @@ export function createExpenseColumns(
|
||||
accessorKey: 'description',
|
||||
header: () => <span>Keterangan</span>,
|
||||
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 <ReceiptPreview url={receiptUrl} title={row.original.description} />;
|
||||
return (
|
||||
<ReceiptPreview
|
||||
url={receiptUrl}
|
||||
title={row.original.description}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -134,9 +141,7 @@ export function createExpenseColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Jumlah</span>
|
||||
@ -144,7 +149,7 @@ export function createExpenseColumns(
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-red-600 font-medium">
|
||||
<span className="font-medium text-red-600">
|
||||
- {formatCurrency(row.getValue('amount') as number)}
|
||||
</span>
|
||||
),
|
||||
@ -176,16 +181,12 @@ export function createExpenseColumns(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleEdit(expense)
|
||||
}
|
||||
onClick={() => handleEdit(expense)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
|
||||
@ -17,7 +17,12 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 type { Expense } from './columns';
|
||||
|
||||
@ -35,14 +40,25 @@ export default function ExpenseIndex({ expenses }: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = 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 [createUploading, setCreateUploading] = useState(false);
|
||||
const [editUploading, setEditUploading] = useState(false);
|
||||
const [createFileMeta, setCreateFileMeta] = useState<{ size: number; type: string } | null>(null);
|
||||
const [editFileMeta, setEditFileMeta] = useState<{ size: number; type: string } | null>(null);
|
||||
const [createFileMeta, setCreateFileMeta] = useState<{
|
||||
size: number;
|
||||
type: string;
|
||||
} | null>(null);
|
||||
const [editFileMeta, setEditFileMeta] = useState<{
|
||||
size: number;
|
||||
type: string;
|
||||
} | null>(null);
|
||||
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 = {
|
||||
current_page: expenses.current_page,
|
||||
@ -52,45 +68,64 @@ export default function ExpenseIndex({ expenses }: Props) {
|
||||
};
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(expenseIndex.url(), {
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
expenseIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(expenseIndex.url(), {
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
expenseIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
router.get(expenseIndex.url(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
}, [pagination.per_page, sort]);
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
expenseIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page, sort],
|
||||
);
|
||||
|
||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||
setSort({ column, direction });
|
||||
router.get(expenseIndex.url(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
expenseIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
@ -122,14 +157,17 @@ export default function ExpenseIndex({ expenses }: Props) {
|
||||
Pengeluaran
|
||||
</h2>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setCreateReceiptKey(null);
|
||||
setCreateFileMeta(null);
|
||||
}
|
||||
}}>
|
||||
if (!open) {
|
||||
setCreateReceiptKey(null);
|
||||
setCreateFileMeta(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
@ -140,69 +178,130 @@ export default function ExpenseIndex({ expenses }: Props) {
|
||||
</button>
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<Form action={store()} resetOnSuccess onSuccess={() => {
|
||||
setCreateOpen(false);
|
||||
setCreateReceiptKey(null);
|
||||
setCreateFileMeta(null);
|
||||
}}>
|
||||
<Form
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setCreateOpen(false);
|
||||
setCreateReceiptKey(null);
|
||||
setCreateFileMeta(null);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Pengeluaran</DialogTitle>
|
||||
<DialogTitle>
|
||||
Tambah Pengeluaran
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '} <span className="text-destructive">*</span>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput name="amount" min={1} />
|
||||
<InputError message={errors.amount} />
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.amount}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">
|
||||
Keterangan{' '} <span className="text-destructive">*</span>
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
<InputError
|
||||
message={
|
||||
errors.description
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Bukti{' '} <span className="text-destructive">*</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 ?? ''} />
|
||||
<Label>
|
||||
Bukti{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</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
|
||||
value={createReceiptKey}
|
||||
onChange={setCreateReceiptKey}
|
||||
onChange={
|
||||
setCreateReceiptKey
|
||||
}
|
||||
folder="expense"
|
||||
onUploadingChange={setCreateUploading}
|
||||
onFileMeta={setCreateFileMeta}
|
||||
onUploadingChange={
|
||||
setCreateUploading
|
||||
}
|
||||
onFileMeta={
|
||||
setCreateFileMeta
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.receipt_key
|
||||
}
|
||||
/>
|
||||
<InputError message={errors.receipt_key} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
onClick={() =>
|
||||
setCreateOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={processing || createUploading}
|
||||
type="submit"
|
||||
disabled={
|
||||
processing ||
|
||||
createUploading
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: createUploading
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
@ -240,48 +339,114 @@ export default function ExpenseIndex({ expenses }: Props) {
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditReceiptKey(null);
|
||||
setEditFileMeta(null);
|
||||
}}>
|
||||
<Form
|
||||
action={update(editing.id)}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditReceiptKey(null);
|
||||
setEditFileMeta(null);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Pengeluaran</DialogTitle>
|
||||
<DialogTitle>
|
||||
Edit Pengeluaran
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Jumlah{' '} <span className="text-destructive">*</span></Label>
|
||||
<RupiahInput name="amount" defaultValue={editing.amount} min={1} />
|
||||
<InputError message={errors.amount} />
|
||||
<Label>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
defaultValue={
|
||||
editing.amount
|
||||
}
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.amount}
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
id="edit-description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
defaultValue={editing.description}
|
||||
defaultValue={
|
||||
editing.description
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.description
|
||||
}
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Bukti{' '} <span className="text-destructive">*</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 ?? ''} />
|
||||
<Label>
|
||||
Bukti{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</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
|
||||
value={editReceiptKey}
|
||||
onChange={setEditReceiptKey}
|
||||
onChange={
|
||||
setEditReceiptKey
|
||||
}
|
||||
folder="expense"
|
||||
onUploadingChange={setEditUploading}
|
||||
existingUrl={editing.receipt_url}
|
||||
onFileMeta={setEditFileMeta}
|
||||
onUploadingChange={
|
||||
setEditUploading
|
||||
}
|
||||
existingUrl={
|
||||
editing.receipt_url
|
||||
}
|
||||
onFileMeta={
|
||||
setEditFileMeta
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.receipt_key
|
||||
}
|
||||
/>
|
||||
<InputError message={errors.receipt_key} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
@ -296,13 +461,16 @@ export default function ExpenseIndex({ expenses }: Props) {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing || editUploading}
|
||||
disabled={
|
||||
processing ||
|
||||
editUploading
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: editUploading
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
||||
@ -27,8 +27,19 @@ export type PayrollPeriod = {
|
||||
};
|
||||
|
||||
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 {
|
||||
@ -72,9 +83,7 @@ export function createPayrollPeriodColumns(
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
<span className="block text-center">{row.index + 1}</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
@ -88,9 +97,7 @@ export function createPayrollPeriodColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Periode</span>
|
||||
@ -98,14 +105,18 @@ export function createPayrollPeriodColumns(
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{formatPeriod(row.original)}</span>
|
||||
<span className="font-medium">
|
||||
{formatPeriod(row.original)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'payrolls_count',
|
||||
header: () => <span>Jumlah Karyawan</span>,
|
||||
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"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Total Gaji</span>
|
||||
@ -126,7 +135,10 @@ export function createPayrollPeriodColumns(
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
@ -137,9 +149,7 @@ export function createPayrollPeriodColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Bonus</span>
|
||||
@ -147,11 +157,19 @@ export function createPayrollPeriodColumns(
|
||||
</Button>
|
||||
),
|
||||
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 (
|
||||
<span className={value > 0 ? 'text-green-600 font-medium' : 'text-muted-foreground'}>
|
||||
{value > 0 ? '+ ' : ''}{formatCurrency(value)}
|
||||
<span
|
||||
className={
|
||||
value > 0
|
||||
? 'font-medium text-green-600'
|
||||
: 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{value > 0 ? '+ ' : ''}
|
||||
{formatCurrency(value)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@ -163,9 +181,7 @@ export function createPayrollPeriodColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Potongan</span>
|
||||
@ -173,11 +189,20 @@ export function createPayrollPeriodColumns(
|
||||
</Button>
|
||||
),
|
||||
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 (
|
||||
<span className={value > 0 ? 'text-red-600 font-medium' : 'text-muted-foreground'}>
|
||||
{value > 0 ? '- ' : ''}{formatCurrency(value)}
|
||||
<span
|
||||
className={
|
||||
value > 0
|
||||
? 'font-medium text-red-600'
|
||||
: 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{value > 0 ? '- ' : ''}
|
||||
{formatCurrency(value)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@ -195,13 +220,19 @@ export function createPayrollPeriodColumns(
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
{paid > 0 && (
|
||||
<span className="text-green-600">{paid} dibayar</span>
|
||||
<span className="text-green-600">
|
||||
{paid} dibayar
|
||||
</span>
|
||||
)}
|
||||
{unpaid > 0 && (
|
||||
<span className="text-yellow-600">{unpaid} menunggu</span>
|
||||
<span className="text-yellow-600">
|
||||
{unpaid} menunggu
|
||||
</span>
|
||||
)}
|
||||
{cancelled > 0 && (
|
||||
<span className="text-red-600">{cancelled} dibatalkan</span>
|
||||
<span className="text-red-600">
|
||||
{cancelled} dibatalkan
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -229,11 +260,7 @@ export function createPayrollPeriodColumns(
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
asChild
|
||||
>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={showUrl(period.id)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
@ -28,15 +28,29 @@ type Props = {
|
||||
};
|
||||
|
||||
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) {
|
||||
const [closing, setClosing] = useState<PayrollPeriod | null>(null);
|
||||
const [reopening, setReopening] = useState<PayrollPeriod | null>(null);
|
||||
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 = {
|
||||
current_page: payrollPeriods.current_page,
|
||||
@ -47,64 +61,91 @@ export default function PayrollPeriodIndex({ payrollPeriods }: Props) {
|
||||
|
||||
function handleClose() {
|
||||
if (!closing) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
router.post(close(closing.id), {}, {
|
||||
onSuccess: () => setClosing(null),
|
||||
});
|
||||
router.post(
|
||||
close(closing.id),
|
||||
{},
|
||||
{
|
||||
onSuccess: () => setClosing(null),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleReopen() {
|
||||
if (!reopening) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
router.post(reopen(reopening.id), {}, {
|
||||
onSuccess: () => setReopening(null),
|
||||
});
|
||||
router.post(
|
||||
reopen(reopening.id),
|
||||
{},
|
||||
{
|
||||
onSuccess: () => setReopening(null),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(payrollPeriodsIndex(), {
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
payrollPeriodsIndex(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(payrollPeriodsIndex(), {
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
payrollPeriodsIndex(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
router.get(payrollPeriodsIndex(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
}, [pagination.per_page, sort]);
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
payrollPeriodsIndex(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page, sort],
|
||||
);
|
||||
|
||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||
setSort({ column, direction });
|
||||
router.get(payrollPeriodsIndex(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
payrollPeriodsIndex(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const columns = createPayrollPeriodColumns({
|
||||
@ -145,8 +186,8 @@ return;
|
||||
open={closing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setClosing(null);
|
||||
}
|
||||
setClosing(null);
|
||||
}
|
||||
}}
|
||||
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.`}
|
||||
@ -158,8 +199,8 @@ setClosing(null);
|
||||
open={reopening !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setReopening(null);
|
||||
}
|
||||
setReopening(null);
|
||||
}
|
||||
}}
|
||||
title="Buka Periode Gaji"
|
||||
description={`Apakah Anda yakin ingin membuka kembali periode gaji ${reopening ? `${MONTH_NAMES[reopening.month]} ${reopening.year}` : ''}?`}
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@ -65,22 +71,28 @@ type CreateColumnsParams = {
|
||||
handlePay: (payroll: Payroll) => void;
|
||||
handleCancel: (payroll: Payroll) => void;
|
||||
handleAddAdjustment: (payroll: Payroll) => void;
|
||||
handleDeleteAdjustment: (adjustment: PayrollAdjustment, payrollId: number) => void;
|
||||
handleDeleteAdjustment: (
|
||||
adjustment: PayrollAdjustment,
|
||||
payrollId: number,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function createPayrollColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<Payroll>[] {
|
||||
const { handlePay, handleCancel, handleAddAdjustment, handleDeleteAdjustment } = params;
|
||||
const {
|
||||
handlePay,
|
||||
handleCancel,
|
||||
handleAddAdjustment,
|
||||
handleDeleteAdjustment,
|
||||
} = params;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
<span className="block text-center">{row.index + 1}</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
@ -93,7 +105,11 @@ export function createPayrollColumns(
|
||||
cell: ({ row }) => {
|
||||
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"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Gaji Pokok</span>
|
||||
@ -113,7 +127,9 @@ export function createPayrollColumns(
|
||||
</Button>
|
||||
),
|
||||
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"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Bonus</span>
|
||||
@ -136,8 +150,13 @@ export function createPayrollColumns(
|
||||
const value = row.getValue('bonus_amount') as number;
|
||||
|
||||
return (
|
||||
<span className={value > 0 ? 'text-green-600 font-medium' : ''}>
|
||||
{value > 0 ? '+ ' : ''}{formatCurrency(value)}
|
||||
<span
|
||||
className={
|
||||
value > 0 ? 'font-medium text-green-600' : ''
|
||||
}
|
||||
>
|
||||
{value > 0 ? '+ ' : ''}
|
||||
{formatCurrency(value)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@ -149,9 +168,7 @@ export function createPayrollColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Potongan</span>
|
||||
@ -162,8 +179,11 @@ export function createPayrollColumns(
|
||||
const value = row.getValue('deduction_amount') as number;
|
||||
|
||||
return (
|
||||
<span className={value > 0 ? 'text-red-600 font-medium' : ''}>
|
||||
{value > 0 ? '- ' : ''}{formatCurrency(value)}
|
||||
<span
|
||||
className={value > 0 ? 'font-medium text-red-600' : ''}
|
||||
>
|
||||
{value > 0 ? '- ' : ''}
|
||||
{formatCurrency(value)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@ -175,9 +195,7 @@ export function createPayrollColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Total</span>
|
||||
@ -185,7 +203,9 @@ export function createPayrollColumns(
|
||||
</Button>
|
||||
),
|
||||
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 (
|
||||
<div className="flex flex-col gap-1">
|
||||
{adjustments.map((adj: PayrollAdjustment) => (
|
||||
<div key={adj.id} 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)}
|
||||
<div
|
||||
key={adj.id}
|
||||
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 className="text-muted-foreground truncate max-w-[100px]">
|
||||
<span className="max-w-[100px] truncate text-muted-foreground">
|
||||
{adj.description}
|
||||
</span>
|
||||
{payroll.status === 'unpaid' && (
|
||||
<button
|
||||
onClick={() => handleDeleteAdjustment(adj, payroll.id)}
|
||||
onClick={() =>
|
||||
handleDeleteAdjustment(
|
||||
adj,
|
||||
payroll.id,
|
||||
)
|
||||
}
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
>
|
||||
<XCircle className="h-3 w-3" />
|
||||
@ -250,7 +285,9 @@ export function createPayrollColumns(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleAddAdjustment(payroll)}
|
||||
onClick={() =>
|
||||
handleAddAdjustment(payroll)
|
||||
}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@ -265,7 +302,9 @@ export function createPayrollColumns(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handlePay(payroll)}
|
||||
onClick={() =>
|
||||
handlePay(payroll)
|
||||
}
|
||||
>
|
||||
<CircleDollarSign className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
@ -280,7 +319,9 @@ export function createPayrollColumns(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleCancel(payroll)}
|
||||
onClick={() =>
|
||||
handleCancel(payroll)
|
||||
}
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
|
||||
@ -15,9 +15,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { destroy as adjustmentDestroy } from '@/routes/admin/finance/payroll-adjustments';
|
||||
import {
|
||||
index as payrollPeriodsIndex
|
||||
} from '@/routes/admin/finance/payroll-periods';
|
||||
import { index as payrollPeriodsIndex } from '@/routes/admin/finance/payroll-periods';
|
||||
import {
|
||||
cancel as payrollCancel,
|
||||
pay as payrollPay,
|
||||
@ -40,31 +38,55 @@ type Props = {
|
||||
};
|
||||
|
||||
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) {
|
||||
const [paying, setPaying] = useState<Payroll | null>(null);
|
||||
const [cancelling, setCancelling] = useState<Payroll | null>(null);
|
||||
const [addingAdjustment, setAddingAdjustment] = useState<Payroll | null>(null);
|
||||
const [deletingAdjustment, setDeletingAdjustment] = useState<{ adjustment: PayrollAdjustment; payrollId: number } | null>(null);
|
||||
const [addingAdjustment, setAddingAdjustment] = useState<Payroll | null>(
|
||||
null,
|
||||
);
|
||||
const [deletingAdjustment, setDeletingAdjustment] = useState<{
|
||||
adjustment: PayrollAdjustment;
|
||||
payrollId: number;
|
||||
} | null>(null);
|
||||
const [adjustmentType, setAdjustmentType] = useState<string>('bonus');
|
||||
|
||||
function handlePay() {
|
||||
if (!paying) return;
|
||||
|
||||
router.post(payrollPay(paying.id), {}, {
|
||||
onSuccess: () => setPaying(null),
|
||||
});
|
||||
router.post(
|
||||
payrollPay(paying.id),
|
||||
{},
|
||||
{
|
||||
onSuccess: () => setPaying(null),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (!cancelling) return;
|
||||
|
||||
router.post(payrollCancel(cancelling.id), {}, {
|
||||
onSuccess: () => setCancelling(null),
|
||||
});
|
||||
router.post(
|
||||
payrollCancel(cancelling.id),
|
||||
{},
|
||||
{
|
||||
onSuccess: () => setCancelling(null),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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 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);
|
||||
const totalBaseSalary = payrollPeriod.payrolls.reduce(
|
||||
(sum, p) => sum + p.base_salary,
|
||||
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 (
|
||||
<>
|
||||
<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 items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Gaji {MONTH_NAMES[payrollPeriod.month]} {payrollPeriod.year}
|
||||
Gaji {MONTH_NAMES[payrollPeriod.month]}{' '}
|
||||
{payrollPeriod.year}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{payrollPeriod.payrolls.length} karyawan · Status: {payrollPeriod.status === 'open' ? 'Terbuka' : 'Ditutup'}
|
||||
{payrollPeriod.payrolls.length} karyawan ·
|
||||
Status:{' '}
|
||||
{payrollPeriod.status === 'open'
|
||||
? 'Terbuka'
|
||||
: 'Ditutup'}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant='outline'>
|
||||
<Button asChild variant="outline">
|
||||
<a href={payrollPeriodsIndex.url()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
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="rounded-lg border p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Gaji Pokok</p>
|
||||
<p className="text-lg font-semibold">{formatCurrency(totalBaseSalary)}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Total Gaji Pokok
|
||||
</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{formatCurrency(totalBaseSalary)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Bonus</p>
|
||||
<p className="text-lg font-semibold text-green-600">{formatCurrency(totalBonus)}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Total Bonus
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-green-600">
|
||||
{formatCurrency(totalBonus)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Potongan</p>
|
||||
<p className="text-lg font-semibold text-red-600">{formatCurrency(totalDeduction)}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Total Potongan
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-red-600">
|
||||
{formatCurrency(totalDeduction)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Gaji</p>
|
||||
<p className="text-lg font-semibold">{formatCurrency(totalAmount)}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Total Gaji
|
||||
</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{formatCurrency(totalAmount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -142,12 +199,15 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
||||
/>
|
||||
|
||||
{/* Dialog Tambah Penyesuaian */}
|
||||
<Dialog open={addingAdjustment !== null} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setAddingAdjustment(null);
|
||||
setAdjustmentType('bonus');
|
||||
}
|
||||
}}>
|
||||
<Dialog
|
||||
open={addingAdjustment !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setAddingAdjustment(null);
|
||||
setAdjustmentType('bonus');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
{addingAdjustment && (
|
||||
<Form
|
||||
@ -161,59 +221,108 @@ export default function PayrollPeriodShow({ payrollPeriod }: Props) {
|
||||
{({ errors, processing }) => (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Penyesuaian</DialogTitle>
|
||||
<DialogTitle>
|
||||
Tambah Penyesuaian
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jenis{' '} <span className="text-destructive">*</span>
|
||||
Jenis{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input type="hidden" name="type" value={adjustmentType} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="type"
|
||||
value={adjustmentType}
|
||||
/>
|
||||
<RadioGroup
|
||||
value={adjustmentType}
|
||||
onValueChange={setAdjustmentType}
|
||||
onValueChange={
|
||||
setAdjustmentType
|
||||
}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="bonus" id="bonus" />
|
||||
<Label htmlFor="bonus" className="font-normal">Bonus</Label>
|
||||
<RadioGroupItem
|
||||
value="bonus"
|
||||
id="bonus"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="bonus"
|
||||
className="font-normal"
|
||||
>
|
||||
Bonus
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="deduction" id="deduction" />
|
||||
<Label htmlFor="deduction" className="font-normal">Potongan</Label>
|
||||
<RadioGroupItem
|
||||
value="deduction"
|
||||
id="deduction"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="deduction"
|
||||
className="font-normal"
|
||||
>
|
||||
Potongan
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<InputError message={errors.type} />
|
||||
<InputError
|
||||
message={errors.type}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '} <span className="text-destructive">*</span>
|
||||
Jumlah{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput name="amount" min={1} />
|
||||
<InputError message={errors.amount} />
|
||||
<RupiahInput
|
||||
name="amount"
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.amount}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="adjustment-description">
|
||||
Keterangan{' '} <span className="text-destructive">*</span>
|
||||
Keterangan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="adjustment-description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
<InputError
|
||||
message={errors.description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAddingAdjustment(null)}
|
||||
onClick={() =>
|
||||
setAddingAdjustment(null)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
||||
@ -3,12 +3,32 @@ import { LocationMap } from '@/components/location-map';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { index as attendanceIndex, store, update } from '@/routes/admin/hr/attendances';
|
||||
import {
|
||||
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 { addMonths, format, subMonths } from 'date-fns';
|
||||
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 { toast } from 'sonner';
|
||||
|
||||
@ -56,12 +76,20 @@ function getFirstDayOfMonth(year: number, month: number): number {
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
@ -69,7 +97,11 @@ function isWeekend(date: Date): boolean {
|
||||
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;
|
||||
const d = new Date(checkInAt);
|
||||
const h = d.getHours();
|
||||
@ -77,7 +109,11 @@ function isLate(checkInAt: string | null, officeHour: number, officeMinute: numb
|
||||
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;
|
||||
const d = new Date(checkInAt);
|
||||
const officeStart = new Date(d);
|
||||
@ -93,14 +129,29 @@ function formatMinutes(minutes: number | null): string {
|
||||
return `${hours} jam ${mins} menit`;
|
||||
}
|
||||
|
||||
export default function AttendanceIndex({ attendances, 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));
|
||||
export default function AttendanceIndex({
|
||||
attendances,
|
||||
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 [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 [detailAttendance, setDetailAttendance] = useState<Attendance | null>(null);
|
||||
const [detailAttendance, setDetailAttendance] = useState<Attendance | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const viewYear = viewDate.getFullYear();
|
||||
const viewMonth = viewDate.getMonth() + 1;
|
||||
@ -121,7 +172,10 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
||||
const calendarDays = useMemo(() => {
|
||||
const daysInMonth = getDaysInMonth(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 }[] = [];
|
||||
|
||||
@ -129,18 +183,30 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
||||
const d = prevMonthDays - i;
|
||||
const m = viewMonth === 1 ? 12 : viewMonth - 1;
|
||||
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++) {
|
||||
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;
|
||||
for (let i = 1; i <= remaining; i++) {
|
||||
const m = viewMonth === 12 ? 1 : viewMonth + 1;
|
||||
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;
|
||||
@ -164,20 +230,30 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
||||
setLocationLoading(false);
|
||||
const formData = new FormData();
|
||||
formData.append('photo', dataUrl);
|
||||
formData.append('latitude', position.coords.latitude.toString());
|
||||
formData.append('longitude', position.coords.longitude.toString());
|
||||
formData.append(
|
||||
'latitude',
|
||||
position.coords.latitude.toString(),
|
||||
);
|
||||
formData.append(
|
||||
'longitude',
|
||||
position.coords.longitude.toString(),
|
||||
);
|
||||
|
||||
if (actionType === 'check-in') {
|
||||
router.post(store(), formData, { preserveScroll: true });
|
||||
} else if (actionType === 'check-out' && todayAttendance) {
|
||||
router.put(update(todayAttendance.id), formData, { preserveScroll: true });
|
||||
router.put(update(todayAttendance.id), formData, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
() => {
|
||||
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>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Presensi</h2>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Presensi
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 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-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-muted-foreground">Masuk:</span>
|
||||
<span className="font-medium">{formatTime(todayAttendance?.check_in_at)}</span>
|
||||
<span className="text-muted-foreground">
|
||||
Masuk:
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatTime(
|
||||
todayAttendance?.check_in_at,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasCheckedOut ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-muted-foreground">Pulang:</span>
|
||||
<span className="font-medium">{formatTime(todayAttendance?.check_out_at)}</span>
|
||||
<span className="text-muted-foreground">
|
||||
Pulang:
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatTime(
|
||||
todayAttendance?.check_out_at,
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="h-3.5 w-3.5 text-orange-500" />
|
||||
<span className="text-muted-foreground">Pulang:</span>
|
||||
<span className="font-medium text-orange-600">Belum</span>
|
||||
<span className="text-muted-foreground">
|
||||
Pulang:
|
||||
</span>
|
||||
<span className="font-medium text-orange-600">
|
||||
Belum
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</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 className="flex items-center gap-2">
|
||||
{locationLoading && (
|
||||
<span className="text-xs text-muted-foreground">Mendapatkan lokasi...</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Mendapatkan lokasi...
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
@ -259,7 +357,11 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCheckOut}
|
||||
disabled={!hasCheckedIn || hasCheckedOut || locationLoading}
|
||||
disabled={
|
||||
!hasCheckedIn ||
|
||||
hasCheckedOut ||
|
||||
locationLoading
|
||||
}
|
||||
>
|
||||
<LogOut className="mr-1.5 h-3.5 w-3.5" />
|
||||
Presensi Pulang
|
||||
@ -277,8 +379,12 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
||||
<CalendarDays className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Hari Kerja</p>
|
||||
<p className="text-lg font-bold">{monthStats.working_days}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hari Kerja
|
||||
</p>
|
||||
<p className="text-lg font-bold">
|
||||
{monthStats.working_days}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -288,8 +394,12 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Hadir</p>
|
||||
<p className="text-lg font-bold">{monthStats.present}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hadir
|
||||
</p>
|
||||
<p className="text-lg font-bold">
|
||||
{monthStats.present}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -299,8 +409,12 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
||||
<UserX className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Tidak Hadir</p>
|
||||
<p className="text-lg font-bold">{monthStats.absent}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tidak Hadir
|
||||
</p>
|
||||
<p className="text-lg font-bold">
|
||||
{monthStats.absent}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -310,41 +424,72 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
||||
<Wallet className="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Cuti</p>
|
||||
<p className="text-lg font-bold">{monthStats.leave}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Cuti
|
||||
</p>
|
||||
<p className="text-lg font-bold">
|
||||
{monthStats.leave}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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 */}
|
||||
<div className="flex items-center justify-between border-b px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col items-center overflow-hidden rounded-lg ring-1 ring-border">
|
||||
<div className="bg-muted px-3 py-0.5">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{format(viewDate, 'MMM', { locale: id })}
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
{format(viewDate, 'MMM', {
|
||||
locale: id,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{format(viewDate, 'MMMM yyyy', { locale: id })}
|
||||
{format(viewDate, 'MMMM yyyy', {
|
||||
locale: id,
|
||||
})}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<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" />
|
||||
</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
|
||||
</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" />
|
||||
</Button>
|
||||
</div>
|
||||
@ -353,7 +498,10 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
||||
{/* Weekday Headers */}
|
||||
<div className="grid grid-cols-7 border-b">
|
||||
{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}
|
||||
</div>
|
||||
))}
|
||||
@ -364,8 +512,14 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
||||
{calendarDays.map((cell, idx) => {
|
||||
const dateStr = format(cell.date, 'yyyy-MM-dd');
|
||||
const attendance = attendanceDates.get(dateStr);
|
||||
const isSelected = isSameDay(cell.date, selectedDate);
|
||||
const isTodayDate = isSameDay(cell.date, new Date());
|
||||
const isSelected = isSameDay(
|
||||
cell.date,
|
||||
selectedDate,
|
||||
);
|
||||
const isTodayDate = isSameDay(
|
||||
cell.date,
|
||||
new Date(),
|
||||
);
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
@ -373,20 +527,39 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
||||
cellDate.setHours(0, 0, 0, 0);
|
||||
|
||||
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 lateMins = attendance ? getLateMinutes(attendance.check_in_at, officeHour, officeMinute) : 0;
|
||||
const late = attendance
|
||||
? isLate(
|
||||
attendance.check_in_at,
|
||||
officeHour,
|
||||
officeMinute,
|
||||
)
|
||||
: false;
|
||||
const lateMins = attendance
|
||||
? getLateMinutes(
|
||||
attendance.check_in_at,
|
||||
officeHour,
|
||||
officeMinute,
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => {
|
||||
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 ${
|
||||
!cell.isCurrentMonth ? 'bg-muted/30 text-muted-foreground/50' : ''
|
||||
!cell.isCurrentMonth
|
||||
? 'bg-muted/30 text-muted-foreground/50'
|
||||
: ''
|
||||
}`}
|
||||
style={{
|
||||
borderRight: '1px solid var(--border)',
|
||||
@ -399,8 +572,8 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: isTodayDate
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-foreground'
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{cell.day}
|
||||
@ -409,15 +582,40 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
||||
<div className="mt-1 flex flex-col gap-0.5">
|
||||
{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'}`}>
|
||||
{late ? 'Terlambat' : 'Hadir'}
|
||||
<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'}`}
|
||||
>
|
||||
{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 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 && (
|
||||
<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 && (
|
||||
@ -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">
|
||||
<DialogHeader>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
{detailAttendance && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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 ? (
|
||||
<img
|
||||
src={detailAttendance.check_in_photo}
|
||||
src={
|
||||
detailAttendance.check_in_photo
|
||||
}
|
||||
alt="Foto Masuk"
|
||||
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">
|
||||
<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 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 ? (
|
||||
<img
|
||||
src={detailAttendance.check_out_photo}
|
||||
src={
|
||||
detailAttendance.check_out_photo
|
||||
}
|
||||
alt="Foto Pulang"
|
||||
className="w-full rounded-lg border object-cover"
|
||||
/>
|
||||
@ -485,22 +704,34 @@ export default function AttendanceIndex({ attendances, todayAttendance, currentY
|
||||
{detailAttendance.check_out_at ? (
|
||||
<>
|
||||
<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" />
|
||||
<span className="font-medium text-orange-600">Belum pulang</span>
|
||||
<span className="font-medium text-orange-600">
|
||||
Belum pulang
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
latitude={detailAttendance.check_in_latitude}
|
||||
longitude={detailAttendance.check_in_longitude}
|
||||
latitude={
|
||||
detailAttendance.check_in_latitude
|
||||
}
|
||||
longitude={
|
||||
detailAttendance.check_in_longitude
|
||||
}
|
||||
height="200px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -56,16 +56,19 @@ type CreateColumnsParams = {
|
||||
export function createEmployeeColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<Employee>[] {
|
||||
const { handleEdit, handleDeleteClick, handleResetPassword, toggleActiveUrl } = params;
|
||||
const {
|
||||
handleEdit,
|
||||
handleDeleteClick,
|
||||
handleResetPassword,
|
||||
toggleActiveUrl,
|
||||
} = params;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
<span className="block text-center">{row.index + 1}</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
@ -80,9 +83,7 @@ export function createEmployeeColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Nama</span>
|
||||
@ -111,9 +112,7 @@ export function createEmployeeColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Username</span>
|
||||
@ -133,7 +132,9 @@ export function createEmployeeColumns(
|
||||
cell: ({ row }) => {
|
||||
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"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Status</span>
|
||||
@ -158,7 +157,9 @@ export function createEmployeeColumns(
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
},
|
||||
@ -179,9 +180,13 @@ export function createEmployeeColumns(
|
||||
size="sm"
|
||||
checked={employee.is_active}
|
||||
onCheckedChange={() => {
|
||||
router.post(toggleActiveUrl(employee.id), {}, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
router.post(
|
||||
toggleActiveUrl(employee.id),
|
||||
{},
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -211,9 +216,7 @@ export function createEmployeeColumns(
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
@ -221,7 +224,9 @@ export function createEmployeeColumns(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleResetPassword(employee)}
|
||||
onClick={() =>
|
||||
handleResetPassword(employee)
|
||||
}
|
||||
>
|
||||
<KeyRound className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
@ -236,7 +241,9 @@ export function createEmployeeColumns(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteClick(employee)}
|
||||
onClick={() =>
|
||||
handleDeleteClick(employee)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
|
||||
@ -36,7 +36,7 @@ export default function EmployeeCreate() {
|
||||
Tambah Pegawai
|
||||
</h2>
|
||||
</div>
|
||||
<Button asChild variant='outline'>
|
||||
<Button asChild variant="outline">
|
||||
<a href={employeeIndex.url()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Kembali
|
||||
@ -52,9 +52,7 @@ export default function EmployeeCreate() {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form
|
||||
action={store()}
|
||||
>
|
||||
<Form action={store()}>
|
||||
{({ errors, processing }) => (
|
||||
<>
|
||||
<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">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">
|
||||
Email <span className="text-destructive">*</span>
|
||||
Email{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
@ -73,18 +74,25 @@ export default function EmployeeCreate() {
|
||||
type="email"
|
||||
placeholder="Masukkan email"
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
<InputError
|
||||
message={errors.email}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">
|
||||
Username <span className="text-destructive">*</span>
|
||||
Username{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder="Masukkan username"
|
||||
/>
|
||||
<InputError message={errors.username} />
|
||||
<InputError
|
||||
message={errors.username}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -96,35 +104,63 @@ export default function EmployeeCreate() {
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="full_name">
|
||||
Nama Lengkap <span className="text-destructive">*</span>
|
||||
Nama Lengkap{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="full_name"
|
||||
name="full_name"
|
||||
placeholder="Masukkan nama lengkap"
|
||||
/>
|
||||
<InputError message={errors.full_name} />
|
||||
<InputError
|
||||
message={errors.full_name}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone_number">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<PhoneNumberInput name="phone_number"/>
|
||||
<InputError message={errors.phone_number} />
|
||||
<PhoneNumberInput name="phone_number" />
|
||||
<InputError
|
||||
message={errors.phone_number}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<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">
|
||||
<RadioGroupItem value="male" id="gender-male" />
|
||||
<Label htmlFor="gender-male" className="font-normal">Laki-laki</Label>
|
||||
<RadioGroupItem
|
||||
value="male"
|
||||
id="gender-male"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="gender-male"
|
||||
className="font-normal"
|
||||
>
|
||||
Laki-laki
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="female" id="gender-female" />
|
||||
<Label htmlFor="gender-female" className="font-normal">Perempuan</Label>
|
||||
<RadioGroupItem
|
||||
value="female"
|
||||
id="gender-female"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="gender-female"
|
||||
className="font-normal"
|
||||
>
|
||||
Perempuan
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<InputError message={errors.gender} />
|
||||
<InputError
|
||||
message={errors.gender}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Tanggal Lahir</Label>
|
||||
@ -134,7 +170,9 @@ export default function EmployeeCreate() {
|
||||
onChange={() => {}}
|
||||
placeholder="Pilih tanggal lahir"
|
||||
/>
|
||||
<InputError message={errors.birth_date} />
|
||||
<InputError
|
||||
message={errors.birth_date}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="address">
|
||||
@ -146,7 +184,9 @@ export default function EmployeeCreate() {
|
||||
placeholder="Masukkan alamat"
|
||||
rows={3}
|
||||
/>
|
||||
<InputError message={errors.address} />
|
||||
<InputError
|
||||
message={errors.address}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -157,14 +197,21 @@ export default function EmployeeCreate() {
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-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
|
||||
name="join_date"
|
||||
value={joinDate}
|
||||
onChange={setJoinDate}
|
||||
placeholder="Pilih tanggal masuk"
|
||||
/>
|
||||
<InputError message={errors.join_date} />
|
||||
<InputError
|
||||
message={errors.join_date}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Tanggal Keluar</Label>
|
||||
@ -174,36 +221,68 @@ export default function EmployeeCreate() {
|
||||
onChange={setResignDate}
|
||||
placeholder="Pilih tanggal keluar"
|
||||
/>
|
||||
<InputError message={errors.resign_date} />
|
||||
<InputError
|
||||
message={errors.resign_date}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Status Kepegawaian <span className="text-destructive">*</span></Label>
|
||||
<Select name="employment_status" defaultValue="full_time">
|
||||
<Label>
|
||||
Status Kepegawaian{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Select
|
||||
name="employment_status"
|
||||
defaultValue="full_time"
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih status kepegawaian" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full_time">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="full_time">
|
||||
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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.employment_status} />
|
||||
<InputError
|
||||
message={
|
||||
errors.employment_status
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="base_salary">
|
||||
Gaji Pokok <span className="text-destructive">*</span>
|
||||
Gaji Pokok{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput name="base_salary" min={1} />
|
||||
<InputError message={errors.base_salary} />
|
||||
<RupiahInput
|
||||
name="base_salary"
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.base_salary}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-6">
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
|
||||
@ -45,13 +45,19 @@ type Props = {
|
||||
|
||||
export default function EmployeeEdit({ employee }: Props) {
|
||||
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>(
|
||||
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>(
|
||||
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 (
|
||||
@ -65,7 +71,7 @@ export default function EmployeeEdit({ employee }: Props) {
|
||||
Edit Pegawai
|
||||
</h2>
|
||||
</div>
|
||||
<Button asChild variant='outline'>
|
||||
<Button asChild variant="outline">
|
||||
<a href={employeeIndex.url()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Kembali
|
||||
@ -87,7 +93,10 @@ export default function EmployeeEdit({ employee }: Props) {
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">
|
||||
Email <span className="text-destructive">*</span>
|
||||
Email{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
@ -96,11 +105,16 @@ export default function EmployeeEdit({ employee }: Props) {
|
||||
placeholder="Masukkan email"
|
||||
defaultValue={employee.email}
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
<InputError
|
||||
message={errors.email}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">
|
||||
Username <span className="text-destructive">*</span>
|
||||
Username{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
@ -108,7 +122,9 @@ export default function EmployeeEdit({ employee }: Props) {
|
||||
placeholder="Masukkan username"
|
||||
defaultValue={employee.username}
|
||||
/>
|
||||
<InputError message={errors.username} />
|
||||
<InputError
|
||||
message={errors.username}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -120,36 +136,71 @@ export default function EmployeeEdit({ employee }: Props) {
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="full_name">
|
||||
Nama Lengkap <span className="text-destructive">*</span>
|
||||
Nama Lengkap{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="full_name"
|
||||
name="full_name"
|
||||
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 className="grid gap-2">
|
||||
<Label htmlFor="phone_number">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<PhoneNumberInput name="phone_number"/>
|
||||
<InputError message={errors.phone_number} />
|
||||
<PhoneNumberInput name="phone_number" />
|
||||
<InputError
|
||||
message={errors.phone_number}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<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">
|
||||
<RadioGroupItem value="male" id="gender-male" />
|
||||
<Label htmlFor="gender-male" className="font-normal">Laki-laki</Label>
|
||||
<RadioGroupItem
|
||||
value="male"
|
||||
id="gender-male"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="gender-male"
|
||||
className="font-normal"
|
||||
>
|
||||
Laki-laki
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="female" id="gender-female" />
|
||||
<Label htmlFor="gender-female" className="font-normal">Perempuan</Label>
|
||||
<RadioGroupItem
|
||||
value="female"
|
||||
id="gender-female"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="gender-female"
|
||||
className="font-normal"
|
||||
>
|
||||
Perempuan
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<InputError message={errors.gender} />
|
||||
<InputError
|
||||
message={errors.gender}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Tanggal Lahir</Label>
|
||||
@ -159,7 +210,9 @@ export default function EmployeeEdit({ employee }: Props) {
|
||||
onChange={setBirthDate}
|
||||
placeholder="Pilih tanggal lahir"
|
||||
/>
|
||||
<InputError message={errors.birth_date} />
|
||||
<InputError
|
||||
message={errors.birth_date}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="address">
|
||||
@ -170,9 +223,14 @@ export default function EmployeeEdit({ employee }: Props) {
|
||||
name="address"
|
||||
placeholder="Masukkan alamat"
|
||||
rows={3}
|
||||
defaultValue={employee.user_profile?.address ?? ''}
|
||||
defaultValue={
|
||||
employee.user_profile
|
||||
?.address ?? ''
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.address}
|
||||
/>
|
||||
<InputError message={errors.address} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -183,14 +241,21 @@ export default function EmployeeEdit({ employee }: Props) {
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-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
|
||||
name="join_date"
|
||||
value={joinDate}
|
||||
onChange={setJoinDate}
|
||||
placeholder="Pilih tanggal masuk"
|
||||
/>
|
||||
<InputError message={errors.join_date} />
|
||||
<InputError
|
||||
message={errors.join_date}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Tanggal Keluar</Label>
|
||||
@ -200,36 +265,72 @@ export default function EmployeeEdit({ employee }: Props) {
|
||||
onChange={setResignDate}
|
||||
placeholder="Pilih tanggal keluar"
|
||||
/>
|
||||
<InputError message={errors.resign_date} />
|
||||
<InputError
|
||||
message={errors.resign_date}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Status Kepegawaian <span className="text-destructive">*</span></Label>
|
||||
<Select name="employment_status" defaultValue={employee.employee?.employment_status ?? 'full_time'}>
|
||||
<Label>
|
||||
Status Kepegawaian{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Select
|
||||
name="employment_status"
|
||||
defaultValue={
|
||||
employee.employee
|
||||
?.employment_status ??
|
||||
'full_time'
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Pilih status kepegawaian" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full_time">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="full_time">
|
||||
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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError message={errors.employment_status} />
|
||||
<InputError
|
||||
message={
|
||||
errors.employment_status
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="base_salary">
|
||||
Gaji Pokok <span className="text-destructive">*</span>
|
||||
Gaji Pokok{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput name="base_salary" min={1} />
|
||||
<InputError message={errors.base_salary} />
|
||||
<RupiahInput
|
||||
name="base_salary"
|
||||
min={1}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.base_salary}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-6">
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
|
||||
@ -5,9 +5,26 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import type { PaginationState, SortState } from '@/components/data-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, 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 {
|
||||
Popover,
|
||||
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 { createEmployeeColumns } from './columns';
|
||||
|
||||
@ -28,10 +45,14 @@ type Props = {
|
||||
|
||||
export default function EmployeeIndex({ employees, filters }: Props) {
|
||||
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 [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 = {
|
||||
current_page: employees.current_page,
|
||||
@ -51,71 +72,98 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
||||
newFilters[key as keyof typeof newFilters] = value;
|
||||
}
|
||||
|
||||
router.get(employeeIndex.url(), {
|
||||
...newFilters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
employeeIndex.url(),
|
||||
{
|
||||
...newFilters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
router.get(employeeIndex.url(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
employeeIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
setFilterOpen(false);
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(employeeIndex.url(), {
|
||||
...filters,
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
employeeIndex.url(),
|
||||
{
|
||||
...filters,
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(employeeIndex.url(), {
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
employeeIndex.url(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
router.get(employeeIndex.url(), {
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
}, [pagination.per_page, sort, filters]);
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
employeeIndex.url(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page, sort, filters],
|
||||
);
|
||||
|
||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||
setSort({ column, direction });
|
||||
router.get(employeeIndex.url(), {
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
employeeIndex.url(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
@ -133,9 +181,13 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.post(resetPasswordRoute.url(resetPasswordTarget.id), {}, {
|
||||
onSuccess: () => setResetPasswordTarget(null),
|
||||
});
|
||||
router.post(
|
||||
resetPasswordRoute.url(resetPasswordTarget.id),
|
||||
{},
|
||||
{
|
||||
onSuccess: () => setResetPasswordTarget(null),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const columns = createEmployeeColumns({
|
||||
@ -183,17 +235,29 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
||||
</label>
|
||||
<Select
|
||||
value={filters.employment_status ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('employment_status', value)}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('employment_status', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="full_time">Full Time</SelectItem>
|
||||
<SelectItem value="part_time">Part Time</SelectItem>
|
||||
<SelectItem value="contract">Kontrak</SelectItem>
|
||||
<SelectItem value="internship">Magang</SelectItem>
|
||||
<SelectItem value="all">
|
||||
Semua Status
|
||||
</SelectItem>
|
||||
<SelectItem value="full_time">
|
||||
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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@ -205,7 +269,9 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
||||
</label>
|
||||
<Select
|
||||
value={filters.is_active ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('is_active', value)}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('is_active', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua" />
|
||||
@ -224,7 +290,9 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
||||
</label>
|
||||
<Select
|
||||
value={filters.gender ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('gender', value)}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('gender', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua" />
|
||||
@ -232,7 +300,9 @@ export default function EmployeeIndex({ employees, filters }: Props) {
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua</SelectItem>
|
||||
<SelectItem value="male">Laki-laki</SelectItem>
|
||||
<SelectItem value="female">Perempuan</SelectItem>
|
||||
<SelectItem value="female">
|
||||
Perempuan
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,13 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
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 = {
|
||||
id: number;
|
||||
@ -74,16 +80,15 @@ type CreateColumnsParams = {
|
||||
export function createLeaveRequestColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<LeaveRequest>[] {
|
||||
const { handleEdit, handleDeleteClick, handleApprove, handleReject } = params;
|
||||
const { handleEdit, handleDeleteClick, handleApprove, handleReject } =
|
||||
params;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
<span className="block text-center">{row.index + 1}</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
@ -96,7 +101,11 @@ export function createLeaveRequestColumns(
|
||||
cell: ({ row }) => {
|
||||
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"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Tanggal Mulai</span>
|
||||
@ -116,7 +123,9 @@ export function createLeaveRequestColumns(
|
||||
</Button>
|
||||
),
|
||||
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"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Tanggal Selesai</span>
|
||||
@ -136,7 +143,9 @@ export function createLeaveRequestColumns(
|
||||
</Button>
|
||||
),
|
||||
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"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Hari</span>
|
||||
@ -224,16 +231,12 @@ export function createLeaveRequestColumns(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleEdit(leaveRequest)
|
||||
}
|
||||
onClick={() => handleEdit(leaveRequest)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
|
||||
@ -15,9 +15,26 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Popover, 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 {
|
||||
Popover,
|
||||
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 { createLeaveRequestColumns } from './columns';
|
||||
|
||||
@ -42,11 +59,18 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
const [rejecting, setRejecting] = useState<LeaveRequest | null>(null);
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
const [editingStartDate, setEditingStartDate] = useState<Date | undefined>(undefined);
|
||||
const [editingEndDate, setEditingEndDate] = useState<Date | undefined>(undefined);
|
||||
const [editingStartDate, setEditingStartDate] = useState<Date | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [editingEndDate, setEditingEndDate] = useState<Date | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
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 = {
|
||||
current_page: leaveRequests.current_page,
|
||||
@ -76,24 +100,32 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
newFilters[key as keyof typeof newFilters] = value;
|
||||
}
|
||||
|
||||
router.get(leaveRequestIndex(), {
|
||||
...newFilters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
leaveRequestIndex(),
|
||||
{
|
||||
...newFilters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
router.get(leaveRequestIndex(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
leaveRequestIndex(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
setFilterOpen(false);
|
||||
}
|
||||
|
||||
@ -112,9 +144,13 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.post(approve(approving.id), {}, {
|
||||
onSuccess: () => setApproving(null),
|
||||
});
|
||||
router.post(
|
||||
approve(approving.id),
|
||||
{},
|
||||
{
|
||||
onSuccess: () => setApproving(null),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleReject() {
|
||||
@ -122,55 +158,78 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.post(reject(rejecting.id), {}, {
|
||||
onSuccess: () => setRejecting(null),
|
||||
});
|
||||
router.post(
|
||||
reject(rejecting.id),
|
||||
{},
|
||||
{
|
||||
onSuccess: () => setRejecting(null),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(leaveRequestIndex(), {
|
||||
...filters,
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
leaveRequestIndex(),
|
||||
{
|
||||
...filters,
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(leaveRequestIndex(), {
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
leaveRequestIndex(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
router.get(leaveRequestIndex(), {
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
}, [pagination.per_page, sort, filters]);
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
leaveRequestIndex(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page, sort, filters],
|
||||
);
|
||||
|
||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||
setSort({ column, direction });
|
||||
router.get(leaveRequestIndex(), {
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
leaveRequestIndex(),
|
||||
{
|
||||
...filters,
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const columns = createLeaveRequestColumns({
|
||||
@ -216,17 +275,29 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
</label>
|
||||
<Select
|
||||
value={filters.status ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('status', value)}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('status', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="pending">Menunggu</SelectItem>
|
||||
<SelectItem value="approved">Disetujui</SelectItem>
|
||||
<SelectItem value="rejected">Ditolak</SelectItem>
|
||||
<SelectItem value="cancelled">Dibatalkan</SelectItem>
|
||||
<SelectItem value="all">
|
||||
Semua Status
|
||||
</SelectItem>
|
||||
<SelectItem value="pending">
|
||||
Menunggu
|
||||
</SelectItem>
|
||||
<SelectItem value="approved">
|
||||
Disetujui
|
||||
</SelectItem>
|
||||
<SelectItem value="rejected">
|
||||
Ditolak
|
||||
</SelectItem>
|
||||
<SelectItem value="cancelled">
|
||||
Dibatalkan
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@ -246,14 +317,17 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
Cuti
|
||||
</h2>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
}
|
||||
}}>
|
||||
if (!open) {
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
@ -264,52 +338,97 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
</button>
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
|
||||
<Form
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setCreateOpen(false)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Permohonan Cuti</DialogTitle>
|
||||
<DialogTitle>
|
||||
Tambah Permohonan Cuti
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Tanggal Mulai{' '} <span className="text-destructive">*</span>
|
||||
Tanggal Mulai{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</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
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
placeholder="Pilih tanggal mulai"
|
||||
min={new Date()}
|
||||
/>
|
||||
<InputError message={errors.start_date} />
|
||||
<InputError
|
||||
message={
|
||||
errors.start_date
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Tanggal Selesai{' '} <span className="text-destructive">*</span>
|
||||
Tanggal Selesai{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</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
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
placeholder="Pilih tanggal selesai"
|
||||
min={startDate}
|
||||
/>
|
||||
<InputError message={errors.end_date} />
|
||||
<InputError
|
||||
message={
|
||||
errors.end_date
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
onClick={() =>
|
||||
setCreateOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
@ -353,40 +472,91 @@ export default function LeaveRequestIndex({ leaveRequests, filters }: Props) {
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditingStartDate(undefined);
|
||||
setEditingEndDate(undefined);
|
||||
}}>
|
||||
<Form
|
||||
action={update(editing.id)}
|
||||
resetOnSuccess
|
||||
onSuccess={() => {
|
||||
setEditing(null);
|
||||
setEditingStartDate(undefined);
|
||||
setEditingEndDate(undefined);
|
||||
}}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Permohonan Cuti</DialogTitle>
|
||||
<DialogTitle>
|
||||
Edit Permohonan Cuti
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Tanggal Mulai{' '} <span className="text-destructive">*</span></Label>
|
||||
<input type="hidden" name="start_date" value={editingStartDate ? editingStartDate.toISOString().split('T')[0] : ''} />
|
||||
<Label>
|
||||
Tanggal Mulai{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="start_date"
|
||||
value={
|
||||
editingStartDate
|
||||
? editingStartDate
|
||||
.toISOString()
|
||||
.split(
|
||||
'T',
|
||||
)[0]
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<DatePicker
|
||||
value={editingStartDate}
|
||||
onChange={setEditingStartDate}
|
||||
onChange={
|
||||
setEditingStartDate
|
||||
}
|
||||
placeholder="Pilih tanggal mulai"
|
||||
min={new Date()}
|
||||
/>
|
||||
<InputError message={errors.start_date} />
|
||||
<InputError
|
||||
message={
|
||||
errors.start_date
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Tanggal Selesai{' '} <span className="text-destructive">*</span></Label>
|
||||
<input type="hidden" name="end_date" value={editingEndDate ? editingEndDate.toISOString().split('T')[0] : ''} />
|
||||
<Label>
|
||||
Tanggal Selesai{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="end_date"
|
||||
value={
|
||||
editingEndDate
|
||||
? editingEndDate
|
||||
.toISOString()
|
||||
.split(
|
||||
'T',
|
||||
)[0]
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<DatePicker
|
||||
value={editingEndDate}
|
||||
onChange={setEditingEndDate}
|
||||
onChange={
|
||||
setEditingEndDate
|
||||
}
|
||||
placeholder="Pilih tanggal selesai"
|
||||
min={editingStartDate}
|
||||
/>
|
||||
<InputError message={errors.end_date} />
|
||||
<InputError
|
||||
message={
|
||||
errors.end_date
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@ -28,9 +28,7 @@ export function createCategoryColumns(
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
<span className="block text-center">{row.index + 1}</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
@ -44,9 +42,7 @@ export function createCategoryColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Nama</span>
|
||||
@ -77,16 +73,12 @@ export function createCategoryColumns(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleEdit(category)
|
||||
}
|
||||
onClick={() => handleEdit(category)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
|
||||
@ -15,7 +15,12 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 type { Category } from './columns';
|
||||
|
||||
@ -34,7 +39,10 @@ export default function CategoryIndex({ categories }: Props) {
|
||||
const [editing, setEditing] = useState<Category | null>(null);
|
||||
const [deleting, setDeleting] = useState<Category | null>(null);
|
||||
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 = {
|
||||
current_page: categories.current_page,
|
||||
@ -44,57 +52,76 @@ export default function CategoryIndex({ categories }: Props) {
|
||||
};
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(categoryIndex.url(), {
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
router.get(
|
||||
categoryIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(categoryIndex.url(), {
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
router.get(
|
||||
categoryIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
router.get(categoryIndex.url(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
}, [pagination.per_page, sort]);
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
categoryIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
[pagination.per_page, sort],
|
||||
);
|
||||
|
||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||
setSort({ column, direction });
|
||||
router.get(categoryIndex.url(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
}, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
router.get(
|
||||
categoryIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
@ -134,37 +161,49 @@ export default function CategoryIndex({ categories }: Props) {
|
||||
</button>
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
|
||||
<Form
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setCreateOpen(false)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Kategori</DialogTitle>
|
||||
<DialogTitle>
|
||||
Tambah Kategori
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama{' '} <span className="text-destructive">*</span>
|
||||
Nama{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama kategori"
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
<InputError
|
||||
message={errors.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
onClick={() =>
|
||||
setCreateOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
@ -205,24 +244,38 @@ export default function CategoryIndex({ categories }: Props) {
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}>
|
||||
<Form
|
||||
action={update(editing.id)}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setEditing(null)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Kategori</DialogTitle>
|
||||
<DialogTitle>
|
||||
Edit Kategori
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<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
|
||||
id="edit-name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama kategori"
|
||||
defaultValue={editing.name}
|
||||
defaultValue={
|
||||
editing.name
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@ -30,9 +30,7 @@ export function createCustomerColumns(
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
<span className="block text-center">{row.index + 1}</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
@ -46,9 +44,7 @@ export function createCustomerColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Nama</span>
|
||||
@ -68,9 +64,7 @@ export function createCustomerColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>No. Telepon</span>
|
||||
@ -78,9 +72,7 @@ export function createCustomerColumns(
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.getValue('phone_number') as string ?? '-'}
|
||||
</span>
|
||||
<span>{(row.getValue('phone_number') as string) ?? '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -90,9 +82,7 @@ export function createCustomerColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Alamat</span>
|
||||
@ -100,8 +90,8 @@ export function createCustomerColumns(
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="max-w-[200px] truncate block">
|
||||
{row.getValue('address') as string ?? '-'}
|
||||
<span className="block max-w-[200px] truncate">
|
||||
{(row.getValue('address') as string) ?? '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@ -123,16 +113,12 @@ export function createCustomerColumns(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleEdit(customer)
|
||||
}
|
||||
onClick={() => handleEdit(customer)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
|
||||
@ -16,7 +16,12 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 { createCustomerColumns } from './columns';
|
||||
|
||||
@ -35,7 +40,10 @@ export default function CustomerIndex({ customers }: Props) {
|
||||
const [editing, setEditing] = useState<Customer | null>(null);
|
||||
const [deleting, setDeleting] = useState<Customer | null>(null);
|
||||
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 = {
|
||||
current_page: customers.current_page,
|
||||
@ -45,57 +53,76 @@ export default function CustomerIndex({ customers }: Props) {
|
||||
};
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(customerIndex.url(), {
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
router.get(
|
||||
customerIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(customerIndex.url(), {
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
router.get(
|
||||
customerIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
router.get(customerIndex.url(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
}, [pagination.per_page, sort]);
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
customerIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
[pagination.per_page, sort],
|
||||
);
|
||||
|
||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||
setSort({ column, direction });
|
||||
router.get(customerIndex.url(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
}, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
router.get(
|
||||
customerIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
@ -135,32 +162,46 @@ export default function CustomerIndex({ customers }: Props) {
|
||||
</button>
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
|
||||
<Form
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setCreateOpen(false)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Customer</DialogTitle>
|
||||
<DialogTitle>
|
||||
Tambah Customer
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama{' '} <span className="text-destructive">*</span>
|
||||
Nama{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama customer"
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
<InputError
|
||||
message={errors.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone_number">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<PhoneNumberInput name="phone_number"/>
|
||||
<InputError message={errors.phone_number} />
|
||||
<PhoneNumberInput name="phone_number" />
|
||||
<InputError
|
||||
message={
|
||||
errors.phone_number
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="address">
|
||||
@ -171,19 +212,23 @@ export default function CustomerIndex({ customers }: Props) {
|
||||
name="address"
|
||||
placeholder="Masukkan alamat"
|
||||
/>
|
||||
<InputError message={errors.address} />
|
||||
<InputError
|
||||
message={errors.address}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
onClick={() =>
|
||||
setCreateOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
@ -224,27 +269,43 @@ export default function CustomerIndex({ customers }: Props) {
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}>
|
||||
<Form
|
||||
action={update(editing.id)}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setEditing(null)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Customer</DialogTitle>
|
||||
<DialogTitle>
|
||||
Edit Customer
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<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
|
||||
id="edit-name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama customer"
|
||||
defaultValue={editing.name}
|
||||
defaultValue={
|
||||
editing.name
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-phone_number">No. Telepon</Label>
|
||||
<Label htmlFor="edit-phone_number">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-phone_number"
|
||||
name="phone_number"
|
||||
@ -252,19 +313,33 @@ export default function CustomerIndex({ customers }: Props) {
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
placeholder="Masukkan nomor telepon"
|
||||
defaultValue={editing.phone_number ?? ''}
|
||||
defaultValue={
|
||||
editing.phone_number ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.phone_number
|
||||
}
|
||||
/>
|
||||
<InputError message={errors.phone_number} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-address">Alamat</Label>
|
||||
<Label htmlFor="edit-address">
|
||||
Alamat
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-address"
|
||||
name="address"
|
||||
placeholder="Masukkan alamat"
|
||||
defaultValue={editing.address ?? ''}
|
||||
defaultValue={
|
||||
editing.address ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.address}
|
||||
/>
|
||||
<InputError message={errors.address} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@ -70,7 +70,10 @@ function formatNumber(num: number): string {
|
||||
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();
|
||||
return query
|
||||
? allVariants.filter((v) => v.name.toLowerCase().includes(query))
|
||||
@ -93,7 +96,8 @@ export function createProductColumns(
|
||||
id: 'expand',
|
||||
header: '',
|
||||
cell: ({ row }) => {
|
||||
const hasVariants = (row.original.product_variants?.length ?? 0) > 0;
|
||||
const hasVariants =
|
||||
(row.original.product_variants?.length ?? 0) > 0;
|
||||
|
||||
if (!hasVariants) {
|
||||
return null;
|
||||
@ -119,7 +123,8 @@ export function createProductColumns(
|
||||
},
|
||||
{
|
||||
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,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
@ -131,9 +136,7 @@ export function createProductColumns(
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
<span className="block text-center">{row.index + 1}</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
@ -147,9 +150,7 @@ export function createProductColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Nama Produk</span>
|
||||
@ -161,11 +162,11 @@ export function createProductColumns(
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{product.name}
|
||||
</span>
|
||||
<span className="font-medium">{product.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{product.categories?.map((c) => c.name).join(', ') || '-'}
|
||||
{product.categories
|
||||
?.map((c) => c.name)
|
||||
.join(', ') || '-'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@ -175,8 +176,14 @@ export function createProductColumns(
|
||||
id: 'variants',
|
||||
header: () => <span>Varian</span>,
|
||||
cell: ({ row, table }) => {
|
||||
const searchValue = (table.getColumn('variant_names')?.getFilterValue() as string) ?? '';
|
||||
const variants = getFilteredVariants(row.original.product_variants ?? [], searchValue);
|
||||
const searchValue =
|
||||
(table
|
||||
.getColumn('variant_names')
|
||||
?.getFilterValue() as string) ?? '';
|
||||
const variants = getFilteredVariants(
|
||||
row.original.product_variants ?? [],
|
||||
searchValue,
|
||||
);
|
||||
|
||||
return (
|
||||
<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',
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const searchValue = (table.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);
|
||||
const searchValue =
|
||||
(table
|
||||
.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 (
|
||||
<span className="block text-center font-medium">
|
||||
@ -206,15 +222,26 @@ export function createProductColumns(
|
||||
},
|
||||
{
|
||||
id: 'reject_stock',
|
||||
header: () => <span className="block text-center">Stok Reject</span>,
|
||||
header: () => (
|
||||
<span className="block text-center">Stok Reject</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[80px] text-center',
|
||||
headerClassName: 'w-[80px] text-center',
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const searchValue = (table.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);
|
||||
const searchValue =
|
||||
(table
|
||||
.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 (
|
||||
<span className="block text-center font-medium">
|
||||
@ -231,9 +258,18 @@ export function createProductColumns(
|
||||
headerClassName: 'w-[80px] text-center',
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const searchValue = (table.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);
|
||||
const searchValue =
|
||||
(table
|
||||
.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 (
|
||||
<span className="block text-center font-medium">
|
||||
@ -250,11 +286,26 @@ export function createProductColumns(
|
||||
headerClassName: 'w-[80px] text-center',
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const searchValue = (table.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);
|
||||
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 searchValue =
|
||||
(table
|
||||
.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,
|
||||
);
|
||||
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;
|
||||
|
||||
return (
|
||||
@ -271,9 +322,7 @@ export function createProductColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Status</span>
|
||||
@ -282,18 +331,26 @@ export function createProductColumns(
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
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';
|
||||
|
||||
function handleToggle(checked: boolean) {
|
||||
router.post(toggleStatusUrl(product.id), {}, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
router.post(
|
||||
toggleStatusUrl(product.id),
|
||||
{},
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!isToggleable) {
|
||||
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)}
|
||||
</span>
|
||||
);
|
||||
@ -306,7 +363,9 @@ export function createProductColumns(
|
||||
checked={isChecked}
|
||||
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)}
|
||||
</span>
|
||||
</div>
|
||||
@ -336,9 +395,7 @@ export function createProductColumns(
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
@ -346,7 +403,9 @@ export function createProductColumns(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteClick(product)}
|
||||
onClick={() =>
|
||||
handleDeleteClick(product)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
|
||||
@ -9,7 +9,14 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { index as productIndex, store } from '@/routes/admin/master/products';
|
||||
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';
|
||||
|
||||
type Category = {
|
||||
@ -50,7 +57,8 @@ type VariantState = {
|
||||
export default function ProductCreate({ categories }: Props) {
|
||||
const [categoryIds, setCategoryIds] = useState<number[]>([]);
|
||||
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[]>([
|
||||
{
|
||||
name: '',
|
||||
@ -85,34 +93,43 @@ export default function ProductCreate({ categories }: Props) {
|
||||
setVariants((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const updateVariant = useCallback((index: number, field: keyof VariantState, value: unknown) => {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
(updated[index] as Record<string, unknown>)[field] = value;
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
const updateVariant = useCallback(
|
||||
(index: number, field: keyof VariantState, value: unknown) => {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
(updated[index] as Record<string, unknown>)[field] = value;
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateVariantPrice = useCallback((variantIndex: number, priceIndex: number, value: number) => {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[variantIndex] = {
|
||||
...updated[variantIndex],
|
||||
prices: updated[variantIndex].prices.map((p, i) =>
|
||||
i === priceIndex ? { ...p, price: value } : p
|
||||
),
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
const updateVariantPrice = useCallback(
|
||||
(variantIndex: number, priceIndex: number, value: number) => {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[variantIndex] = {
|
||||
...updated[variantIndex],
|
||||
prices: updated[variantIndex].prices.map((p, i) =>
|
||||
i === priceIndex ? { ...p, price: value } : p,
|
||||
),
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateSharedPrice = useCallback((priceIndex: number, value: number) => {
|
||||
setSharedPrices((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[priceIndex] = { ...updated[priceIndex], price: value };
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
const updateSharedPrice = useCallback(
|
||||
(priceIndex: number, value: number) => {
|
||||
setSharedPrices((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[priceIndex] = { ...updated[priceIndex], price: value };
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
|
||||
@ -129,10 +146,16 @@ export default function ProductCreate({ categories }: Props) {
|
||||
const pastePrices = useCallback((variantIndex: number) => {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
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) => {
|
||||
const updated = [...prev];
|
||||
updated[variantIndex] = { ...updated[variantIndex], prices };
|
||||
updated[variantIndex] = {
|
||||
...updated[variantIndex],
|
||||
prices,
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
} catch {
|
||||
@ -145,7 +168,7 @@ export default function ProductCreate({ categories }: Props) {
|
||||
setVariants((prev) => {
|
||||
const sourcePrices = prev[variantIndex].prices;
|
||||
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 {
|
||||
category_ids: categoryIds,
|
||||
use_same_price: useSamePrice,
|
||||
shared_prices: useSamePrice ? sharedPrices.map((p) => ({
|
||||
type: p.type,
|
||||
price: Number(p.price),
|
||||
})) : [],
|
||||
shared_prices: useSamePrice
|
||||
? sharedPrices.map((p) => ({
|
||||
type: p.type,
|
||||
price: Number(p.price),
|
||||
}))
|
||||
: [],
|
||||
variants: variantsRef.current.map((v) => ({
|
||||
name: v.name,
|
||||
stock: Number(v.stock),
|
||||
@ -166,7 +191,10 @@ export default function ProductCreate({ categories }: Props) {
|
||||
photo_key: v.photo,
|
||||
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 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">
|
||||
<a href={productIndex.url()}>
|
||||
<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">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama Produk <span className="text-destructive">*</span>
|
||||
Nama Produk{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
@ -213,25 +246,65 @@ export default function ProductCreate({ categories }: Props) {
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Status <span className="text-destructive">*</span></Label>
|
||||
<RadioGroup name="status" defaultValue="active" className="flex gap-4">
|
||||
<Label>
|
||||
Status{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RadioGroup
|
||||
name="status"
|
||||
defaultValue="active"
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="active" id="status-active" />
|
||||
<Label htmlFor="status-active" className="font-normal">Aktif</Label>
|
||||
<RadioGroupItem
|
||||
value="active"
|
||||
id="status-active"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="status-active"
|
||||
className="font-normal"
|
||||
>
|
||||
Aktif
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="inactive" id="status-inactive" />
|
||||
<Label htmlFor="status-inactive" className="font-normal">Non Aktif</Label>
|
||||
<RadioGroupItem
|
||||
value="inactive"
|
||||
id="status-inactive"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="status-inactive"
|
||||
className="font-normal"
|
||||
>
|
||||
Non Aktif
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="draft" id="status-draft" />
|
||||
<Label htmlFor="status-draft" className="font-normal">Draft</Label>
|
||||
<RadioGroupItem
|
||||
value="draft"
|
||||
id="status-draft"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="status-draft"
|
||||
className="font-normal"
|
||||
>
|
||||
Draft
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<InputError message={errors.status} />
|
||||
<InputError
|
||||
message={errors.status}
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
{categories.map((category) => (
|
||||
<label
|
||||
@ -242,24 +315,43 @@ export default function ProductCreate({ categories }: Props) {
|
||||
type="checkbox"
|
||||
name="category_ids[]"
|
||||
value={category.id}
|
||||
checked={categoryIds.includes(category.id)}
|
||||
checked={categoryIds.includes(
|
||||
category.id,
|
||||
)}
|
||||
onChange={(e) => {
|
||||
setCategoryIds((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, category.id]
|
||||
: prev.filter((id) => id !== category.id)
|
||||
setCategoryIds(
|
||||
(prev) =>
|
||||
e.target
|
||||
.checked
|
||||
? [
|
||||
...prev,
|
||||
category.id,
|
||||
]
|
||||
: prev.filter(
|
||||
(
|
||||
id,
|
||||
) =>
|
||||
id !==
|
||||
category.id,
|
||||
),
|
||||
);
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>{category.name}</span>
|
||||
<span>
|
||||
{category.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<InputError message={errors.category_ids} />
|
||||
<InputError
|
||||
message={errors.category_ids}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="description">Deskripsi</Label>
|
||||
<Label htmlFor="description">
|
||||
Deskripsi
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
@ -278,31 +370,80 @@ export default function ProductCreate({ categories }: Props) {
|
||||
<RadioGroup
|
||||
name="use_same_price"
|
||||
value={useSamePrice ? '1' : '0'}
|
||||
onValueChange={(val) => setUseSamePrice(val === '1')}
|
||||
onValueChange={(val) =>
|
||||
setUseSamePrice(val === '1')
|
||||
}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="1" id="price-same" />
|
||||
<Label htmlFor="price-same" className="font-normal">Semua varian sama</Label>
|
||||
<RadioGroupItem
|
||||
value="1"
|
||||
id="price-same"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="price-same"
|
||||
className="font-normal"
|
||||
>
|
||||
Semua varian sama
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="0" id="price-different" />
|
||||
<Label htmlFor="price-different" className="font-normal">Harga per varian</Label>
|
||||
<RadioGroupItem
|
||||
value="0"
|
||||
id="price-different"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="price-different"
|
||||
className="font-normal"
|
||||
>
|
||||
Harga per varian
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{useSamePrice && (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
{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={sharedPrices[priceIndex]?.price ?? 0}
|
||||
onValueChange={(val) => updateSharedPrice(priceIndex, val)}
|
||||
/>
|
||||
<InputError message={errors[`shared_prices.${priceIndex}.price`]} />
|
||||
</div>
|
||||
))}
|
||||
{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={
|
||||
sharedPrices[
|
||||
priceIndex
|
||||
]?.price ??
|
||||
0
|
||||
}
|
||||
onValueChange={(
|
||||
val,
|
||||
) =>
|
||||
updateSharedPrice(
|
||||
priceIndex,
|
||||
val,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors[
|
||||
`shared_prices.${priceIndex}.price`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@ -313,125 +454,311 @@ export default function ProductCreate({ categories }: Props) {
|
||||
<CardTitle>Varian Produk</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{variants.map((variant, variantIndex) => (
|
||||
<div key={variantIndex} className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">Varian {variantIndex + 1}</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
{!useSamePrice && (
|
||||
<>
|
||||
{variants.map(
|
||||
(variant, variantIndex) => (
|
||||
<div
|
||||
key={variantIndex}
|
||||
className="space-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyPrices(variantIndex)}
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
removeVariant(
|
||||
variantIndex,
|
||||
)
|
||||
}
|
||||
>
|
||||
{copiedIndex === variantIndex ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
Salin
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</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
|
||||
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 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>
|
||||
))}
|
||||
<Button type="button" variant="outline" onClick={addVariant}>
|
||||
),
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addVariant}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah Varian
|
||||
</Button>
|
||||
@ -439,13 +766,19 @@ export default function ProductCreate({ categories }: Props) {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-6">
|
||||
<Button type="submit" disabled={processing || variants.some((v) => v.uploading)}>
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
processing ||
|
||||
variants.some((v) => v.uploading)
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: variants.some((v) => v.uploading)
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -9,7 +9,14 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { index as productIndex, update } from '@/routes/admin/master/products';
|
||||
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';
|
||||
|
||||
type Category = {
|
||||
@ -56,9 +63,14 @@ function createEmptyPrices(): Array<{ type: string; price: number }> {
|
||||
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;
|
||||
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 = {
|
||||
@ -74,41 +86,54 @@ type VariantState = {
|
||||
};
|
||||
|
||||
export default function ProductEdit({ product, categories }: Props) {
|
||||
const initialVariants: VariantState[] = product.product_variants.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
stock: v.stock,
|
||||
reject_stock: v.reject_stock,
|
||||
retail_stock: v.retail_stock,
|
||||
photo: v.photo_key,
|
||||
photoUrl: v.photo_url,
|
||||
uploading: false,
|
||||
prices: v.prices.length > 0 ? v.prices : createEmptyPrices(),
|
||||
}));
|
||||
const initialVariants: VariantState[] = product.product_variants.map(
|
||||
(v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
stock: v.stock,
|
||||
reject_stock: v.reject_stock,
|
||||
retail_stock: v.retail_stock,
|
||||
photo: v.photo_key,
|
||||
photoUrl: v.photo_url,
|
||||
uploading: false,
|
||||
prices: v.prices.length > 0 ? v.prices : createEmptyPrices(),
|
||||
}),
|
||||
);
|
||||
|
||||
const allSamePrice = initialVariants.length > 1
|
||||
? initialVariants.every((v) => arePricesEqual(v.prices, initialVariants[0].prices))
|
||||
: true;
|
||||
const allSamePrice =
|
||||
initialVariants.length > 1
|
||||
? 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 [sharedPrices, setSharedPrices] = useState<Array<{ type: string; price: number }>>(
|
||||
initialVariants.length > 0 ? initialVariants[0].prices : createEmptyPrices()
|
||||
const [sharedPrices, setSharedPrices] = useState<
|
||||
Array<{ type: string; price: number }>
|
||||
>(
|
||||
initialVariants.length > 0
|
||||
? initialVariants[0].prices
|
||||
: createEmptyPrices(),
|
||||
);
|
||||
const [variants, setVariants] = useState<VariantState[]>(
|
||||
initialVariants.length > 0 ? initialVariants : [
|
||||
{
|
||||
id: null,
|
||||
name: '',
|
||||
stock: 0,
|
||||
reject_stock: 0,
|
||||
retail_stock: 0,
|
||||
photo: null,
|
||||
photoUrl: null,
|
||||
uploading: false,
|
||||
prices: createEmptyPrices(),
|
||||
},
|
||||
]
|
||||
initialVariants.length > 0
|
||||
? initialVariants
|
||||
: [
|
||||
{
|
||||
id: null,
|
||||
name: '',
|
||||
stock: 0,
|
||||
reject_stock: 0,
|
||||
retail_stock: 0,
|
||||
photo: null,
|
||||
photoUrl: null,
|
||||
uploading: false,
|
||||
prices: createEmptyPrices(),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const variantsRef = useRef(variants);
|
||||
@ -135,34 +160,43 @@ export default function ProductEdit({ product, categories }: Props) {
|
||||
setVariants((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const updateVariant = useCallback((index: number, field: keyof VariantState, value: unknown) => {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
(updated[index] as Record<string, unknown>)[field] = value;
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
const updateVariant = useCallback(
|
||||
(index: number, field: keyof VariantState, value: unknown) => {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
(updated[index] as Record<string, unknown>)[field] = value;
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateVariantPrice = useCallback((variantIndex: number, priceIndex: number, value: number) => {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[variantIndex] = {
|
||||
...updated[variantIndex],
|
||||
prices: updated[variantIndex].prices.map((p, i) =>
|
||||
i === priceIndex ? { ...p, price: value } : p
|
||||
),
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
const updateVariantPrice = useCallback(
|
||||
(variantIndex: number, priceIndex: number, value: number) => {
|
||||
setVariants((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[variantIndex] = {
|
||||
...updated[variantIndex],
|
||||
prices: updated[variantIndex].prices.map((p, i) =>
|
||||
i === priceIndex ? { ...p, price: value } : p,
|
||||
),
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateSharedPrice = useCallback((priceIndex: number, value: number) => {
|
||||
setSharedPrices((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[priceIndex] = { ...updated[priceIndex], price: value };
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
const updateSharedPrice = useCallback(
|
||||
(priceIndex: number, value: number) => {
|
||||
setSharedPrices((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[priceIndex] = { ...updated[priceIndex], price: value };
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
|
||||
@ -179,10 +213,16 @@ export default function ProductEdit({ product, categories }: Props) {
|
||||
const pastePrices = useCallback((variantIndex: number) => {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
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) => {
|
||||
const updated = [...prev];
|
||||
updated[variantIndex] = { ...updated[variantIndex], prices };
|
||||
updated[variantIndex] = {
|
||||
...updated[variantIndex],
|
||||
prices,
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
} catch {
|
||||
@ -195,7 +235,7 @@ export default function ProductEdit({ product, categories }: Props) {
|
||||
setVariants((prev) => {
|
||||
const sourcePrices = prev[variantIndex].prices;
|
||||
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,
|
||||
use_same_price: 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) => ({
|
||||
id: v.id,
|
||||
@ -216,7 +259,10 @@ export default function ProductEdit({ product, categories }: Props) {
|
||||
photo_key: v.photo,
|
||||
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 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">
|
||||
<a href={productIndex.url()}>
|
||||
<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">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama Produk <span className="text-destructive">*</span>
|
||||
Nama Produk{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
@ -265,25 +316,65 @@ export default function ProductEdit({ product, categories }: Props) {
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Status <span className="text-destructive">*</span></Label>
|
||||
<RadioGroup name="status" defaultValue={product.status} className="flex gap-4">
|
||||
<Label>
|
||||
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">
|
||||
<RadioGroupItem value="active" id="status-active" />
|
||||
<Label htmlFor="status-active" className="font-normal">Aktif</Label>
|
||||
<RadioGroupItem
|
||||
value="active"
|
||||
id="status-active"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="status-active"
|
||||
className="font-normal"
|
||||
>
|
||||
Aktif
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="inactive" id="status-inactive" />
|
||||
<Label htmlFor="status-inactive" className="font-normal">Non Aktif</Label>
|
||||
<RadioGroupItem
|
||||
value="inactive"
|
||||
id="status-inactive"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="status-inactive"
|
||||
className="font-normal"
|
||||
>
|
||||
Non Aktif
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="draft" id="status-draft" />
|
||||
<Label htmlFor="status-draft" className="font-normal">Draft</Label>
|
||||
<RadioGroupItem
|
||||
value="draft"
|
||||
id="status-draft"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="status-draft"
|
||||
className="font-normal"
|
||||
>
|
||||
Draft
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<InputError message={errors.status} />
|
||||
<InputError
|
||||
message={errors.status}
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
{categories.map((category) => (
|
||||
<label
|
||||
@ -294,28 +385,49 @@ export default function ProductEdit({ product, categories }: Props) {
|
||||
type="checkbox"
|
||||
name="category_ids[]"
|
||||
value={category.id}
|
||||
checked={categoryIds.includes(category.id)}
|
||||
checked={categoryIds.includes(
|
||||
category.id,
|
||||
)}
|
||||
onChange={(e) => {
|
||||
setCategoryIds((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, category.id]
|
||||
: prev.filter((id) => id !== category.id)
|
||||
setCategoryIds(
|
||||
(prev) =>
|
||||
e.target
|
||||
.checked
|
||||
? [
|
||||
...prev,
|
||||
category.id,
|
||||
]
|
||||
: prev.filter(
|
||||
(
|
||||
id,
|
||||
) =>
|
||||
id !==
|
||||
category.id,
|
||||
),
|
||||
);
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>{category.name}</span>
|
||||
<span>
|
||||
{category.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<InputError message={errors.category_ids} />
|
||||
<InputError
|
||||
message={errors.category_ids}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="description">Deskripsi</Label>
|
||||
<Label htmlFor="description">
|
||||
Deskripsi
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
defaultValue={product.description ?? ''}
|
||||
defaultValue={
|
||||
product.description ?? ''
|
||||
}
|
||||
placeholder="Masukkan deskripsi produk"
|
||||
rows={3}
|
||||
/>
|
||||
@ -331,31 +443,80 @@ export default function ProductEdit({ product, categories }: Props) {
|
||||
<RadioGroup
|
||||
name="use_same_price"
|
||||
value={useSamePrice ? '1' : '0'}
|
||||
onValueChange={(val) => setUseSamePrice(val === '1')}
|
||||
onValueChange={(val) =>
|
||||
setUseSamePrice(val === '1')
|
||||
}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="1" id="price-same" />
|
||||
<Label htmlFor="price-same" className="font-normal">Semua varian sama</Label>
|
||||
<RadioGroupItem
|
||||
value="1"
|
||||
id="price-same"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="price-same"
|
||||
className="font-normal"
|
||||
>
|
||||
Semua varian sama
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="0" id="price-different" />
|
||||
<Label htmlFor="price-different" className="font-normal">Harga per varian</Label>
|
||||
<RadioGroupItem
|
||||
value="0"
|
||||
id="price-different"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="price-different"
|
||||
className="font-normal"
|
||||
>
|
||||
Harga per varian
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{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={sharedPrices[priceIndex]?.price ?? 0}
|
||||
onValueChange={(val) => updateSharedPrice(priceIndex, val)}
|
||||
/>
|
||||
<InputError message={errors[`shared_prices.${priceIndex}.price`]} />
|
||||
</div>
|
||||
))}
|
||||
{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={
|
||||
sharedPrices[
|
||||
priceIndex
|
||||
]?.price ??
|
||||
0
|
||||
}
|
||||
onValueChange={(
|
||||
val,
|
||||
) =>
|
||||
updateSharedPrice(
|
||||
priceIndex,
|
||||
val,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors[
|
||||
`shared_prices.${priceIndex}.price`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@ -366,126 +527,314 @@ export default function ProductEdit({ product, categories }: Props) {
|
||||
<CardTitle>Varian Produk</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{variants.map((variant, variantIndex) => (
|
||||
<div key={variantIndex} className="rounded-lg border p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">Varian {variantIndex + 1}</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
{!useSamePrice && (
|
||||
<>
|
||||
{variants.map(
|
||||
(variant, variantIndex) => (
|
||||
<div
|
||||
key={variantIndex}
|
||||
className="space-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyPrices(variantIndex)}
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
removeVariant(
|
||||
variantIndex,
|
||||
)
|
||||
}
|
||||
>
|
||||
{copiedIndex === variantIndex ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
Salin
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</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
|
||||
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 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>
|
||||
))}
|
||||
<Button type="button" variant="outline" onClick={addVariant}>
|
||||
),
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addVariant}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah Varian
|
||||
</Button>
|
||||
@ -493,13 +842,19 @@ export default function ProductEdit({ product, categories }: Props) {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-6">
|
||||
<Button type="submit" disabled={processing || variants.some((v) => v.uploading)}>
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
processing ||
|
||||
variants.some((v) => v.uploading)
|
||||
}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: variants.some((v) => v.uploading)
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
? 'Mengunggah...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -7,9 +7,26 @@ import type { PaginationState, SortState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Combobox, 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 {
|
||||
Combobox,
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -18,7 +35,13 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} 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 { 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 query = (searchValue ?? '').toLowerCase().trim();
|
||||
const variants = query
|
||||
@ -95,7 +124,10 @@ function VariantSubRow({ row, searchValue }: { row: Row<Product>; searchValue?:
|
||||
<TableBody>
|
||||
{variants.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
Tidak ada varian.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -114,21 +146,36 @@ function VariantSubRow({ row, searchValue }: { row: Row<Product>; searchValue?:
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{variant.name}</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 className="font-medium">
|
||||
{variant.name}
|
||||
</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>
|
||||
{variant.product_prices?.length > 0 ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{variant.product_prices.map((p) => (
|
||||
<span key={p.id} className="text-xs">
|
||||
<span className="text-muted-foreground">{p.type_label}:</span>{' '}
|
||||
<span
|
||||
key={p.id}
|
||||
className="text-xs"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{p.type_label}:
|
||||
</span>{' '}
|
||||
{formatCurrency(p.price)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : '-'}
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@ -142,7 +189,10 @@ export default function ProductIndex({ products, filters }: Props) {
|
||||
const [deleting, setDeleting] = useState<Product | null>(null);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
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;
|
||||
|
||||
@ -175,57 +225,80 @@ export default function ProductIndex({ products, filters }: Props) {
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
router.get(productIndex(), {}, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
router.get(
|
||||
productIndex(),
|
||||
{},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
setFilterOpen(false);
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(productIndex.url(), {
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
...filters,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
productIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
...filters,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(productIndex.url(), {
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
...filters,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
productIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
...filters,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
router.get(productIndex.url(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
...filters,
|
||||
}, { preserveState: true, replace: true });
|
||||
}, [pagination.per_page, sort, filters]);
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
productIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
...filters,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page, sort, filters],
|
||||
);
|
||||
|
||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||
setSort({ column, direction });
|
||||
router.get(productIndex.url(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
...filters,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
productIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
...filters,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
@ -282,11 +355,18 @@ export default function ProductIndex({ products, filters }: Props) {
|
||||
</label>
|
||||
<Combobox
|
||||
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>
|
||||
<ComboboxEmpty>Tidak ada produk ditemukan.</ComboboxEmpty>
|
||||
<ComboboxEmpty>
|
||||
Tidak ada produk ditemukan.
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{productNames.map((name) => (
|
||||
<ComboboxItem key={name} value={name}>
|
||||
@ -304,15 +384,21 @@ export default function ProductIndex({ products, filters }: Props) {
|
||||
</label>
|
||||
<Select
|
||||
value={filters.status ?? 'all'}
|
||||
onValueChange={(value) => applyFilter('status', value)}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('status', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="all">
|
||||
Semua Status
|
||||
</SelectItem>
|
||||
<SelectItem value="active">Aktif</SelectItem>
|
||||
<SelectItem value="inactive">Non Aktif</SelectItem>
|
||||
<SelectItem value="inactive">
|
||||
Non Aktif
|
||||
</SelectItem>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@ -354,7 +440,9 @@ export default function ProductIndex({ products, filters }: Props) {
|
||||
onSortChange={handleSortChange}
|
||||
currentSort={sort}
|
||||
searchValue={search}
|
||||
renderSubRow={(row, searchValue) => <VariantSubRow row={row} searchValue={searchValue} />}
|
||||
renderSubRow={(row, searchValue) => (
|
||||
<VariantSubRow row={row} searchValue={searchValue} />
|
||||
)}
|
||||
defaultExpanded
|
||||
toolbar={filterToolbar}
|
||||
/>
|
||||
|
||||
@ -30,9 +30,7 @@ export function createSupplierColumns(
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
<span className="block text-center">{row.index + 1}</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
@ -46,9 +44,7 @@ export function createSupplierColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Nama</span>
|
||||
@ -68,9 +64,7 @@ export function createSupplierColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>No. Telepon</span>
|
||||
@ -78,9 +72,7 @@ export function createSupplierColumns(
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.getValue('phone_number') as string ?? '-'}
|
||||
</span>
|
||||
<span>{(row.getValue('phone_number') as string) ?? '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -90,9 +82,7 @@ export function createSupplierColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Alamat</span>
|
||||
@ -100,8 +90,8 @@ export function createSupplierColumns(
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="max-w-[200px] truncate block">
|
||||
{row.getValue('address') as string ?? '-'}
|
||||
<span className="block max-w-[200px] truncate">
|
||||
{(row.getValue('address') as string) ?? '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@ -123,16 +113,12 @@ export function createSupplierColumns(
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleEdit(supplier)
|
||||
}
|
||||
onClick={() => handleEdit(supplier)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
|
||||
@ -16,7 +16,12 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 { createSupplierColumns } from './columns';
|
||||
|
||||
@ -35,7 +40,10 @@ export default function SupplierIndex({ suppliers }: Props) {
|
||||
const [editing, setEditing] = useState<Supplier | null>(null);
|
||||
const [deleting, setDeleting] = useState<Supplier | null>(null);
|
||||
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 = {
|
||||
current_page: suppliers.current_page,
|
||||
@ -45,57 +53,76 @@ export default function SupplierIndex({ suppliers }: Props) {
|
||||
};
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(supplierIndex.url(), {
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
router.get(
|
||||
supplierIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(supplierIndex.url(), {
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
router.get(
|
||||
supplierIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
router.get(supplierIndex.url(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
}, [pagination.per_page, sort]);
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
supplierIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
[pagination.per_page, sort],
|
||||
);
|
||||
|
||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||
setSort({ column, direction });
|
||||
router.get(supplierIndex.url(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
}, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
router.get(
|
||||
supplierIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
@ -135,32 +162,46 @@ export default function SupplierIndex({ suppliers }: Props) {
|
||||
</button>
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
|
||||
<Form
|
||||
action={store()}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setCreateOpen(false)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Supplier</DialogTitle>
|
||||
<DialogTitle>
|
||||
Tambah Supplier
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama{' '} <span className="text-destructive">*</span>
|
||||
Nama{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama supplier"
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
<InputError
|
||||
message={errors.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone_number">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<PhoneNumberInput name="phone_number"/>
|
||||
<InputError message={errors.phone_number} />
|
||||
<PhoneNumberInput name="phone_number" />
|
||||
<InputError
|
||||
message={
|
||||
errors.phone_number
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="address">
|
||||
@ -171,19 +212,23 @@ export default function SupplierIndex({ suppliers }: Props) {
|
||||
name="address"
|
||||
placeholder="Masukkan alamat"
|
||||
/>
|
||||
<InputError message={errors.address} />
|
||||
<InputError
|
||||
message={errors.address}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
onClick={() =>
|
||||
setCreateOpen(false)
|
||||
}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
@ -224,27 +269,43 @@ export default function SupplierIndex({ suppliers }: Props) {
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}>
|
||||
<Form
|
||||
action={update(editing.id)}
|
||||
resetOnSuccess
|
||||
onSuccess={() => setEditing(null)}
|
||||
>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Supplier</DialogTitle>
|
||||
<DialogTitle>
|
||||
Edit Supplier
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<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
|
||||
id="edit-name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama supplier"
|
||||
defaultValue={editing.name}
|
||||
defaultValue={
|
||||
editing.name
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.name}
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-phone_number">No. Telepon</Label>
|
||||
<Label htmlFor="edit-phone_number">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-phone_number"
|
||||
name="phone_number"
|
||||
@ -252,19 +313,33 @@ export default function SupplierIndex({ suppliers }: Props) {
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
placeholder="Masukkan nomor telepon"
|
||||
defaultValue={editing.phone_number ?? ''}
|
||||
defaultValue={
|
||||
editing.phone_number ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.phone_number
|
||||
}
|
||||
/>
|
||||
<InputError message={errors.phone_number} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-address">Alamat</Label>
|
||||
<Label htmlFor="edit-address">
|
||||
Alamat
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-address"
|
||||
name="address"
|
||||
placeholder="Masukkan alamat"
|
||||
defaultValue={editing.address ?? ''}
|
||||
defaultValue={
|
||||
editing.address ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.address}
|
||||
/>
|
||||
<InputError message={errors.address} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@ -29,9 +29,7 @@ export function createRoleColumns(
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
<span className="block text-center">{row.index + 1}</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
@ -45,9 +43,7 @@ export function createRoleColumns(
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
<span>Nama Role</span>
|
||||
@ -62,7 +58,9 @@ export function createRoleColumns(
|
||||
},
|
||||
{
|
||||
accessorKey: 'permissions_count',
|
||||
header: () => <span className="block text-center">Jumlah Permission</span>,
|
||||
header: () => (
|
||||
<span className="block text-center">Jumlah Permission</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[180px] text-center',
|
||||
headerClassName: 'w-[180px] text-center',
|
||||
@ -96,9 +94,7 @@ export function createRoleColumns(
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
|
||||
@ -15,41 +15,41 @@ type Props = {
|
||||
};
|
||||
|
||||
const moduleLabels: Record<string, string> = {
|
||||
'user': 'Pengguna',
|
||||
'category': 'Kategori',
|
||||
'supplier': 'Supplier',
|
||||
'customer': 'Customer',
|
||||
user: 'Pengguna',
|
||||
category: 'Kategori',
|
||||
supplier: 'Supplier',
|
||||
customer: 'Customer',
|
||||
'cash-account': 'Kas Toko',
|
||||
'expense': 'Pengeluaran',
|
||||
expense: 'Pengeluaran',
|
||||
'employee-advance': 'Kasbon',
|
||||
'payroll-period': 'Periode Gaji',
|
||||
'payroll': 'Gaji',
|
||||
payroll: 'Gaji',
|
||||
'payroll-adjustment': 'Adjustment Gaji',
|
||||
'leave-request': 'Cuti',
|
||||
'attendance': 'Absensi',
|
||||
'settings': 'Pengaturan',
|
||||
attendance: 'Absensi',
|
||||
settings: 'Pengaturan',
|
||||
};
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
'view': 'Lihat',
|
||||
'create': 'Tambah',
|
||||
'update': 'Edit',
|
||||
'delete': 'Hapus',
|
||||
view: 'Lihat',
|
||||
create: 'Tambah',
|
||||
update: 'Edit',
|
||||
delete: 'Hapus',
|
||||
'toggle-active': 'Aktif/Nonaktif',
|
||||
'reset-password': 'Reset Kata Sandi',
|
||||
'deposit': 'Setor',
|
||||
'withdrawal': 'Tarik',
|
||||
'approve': 'Setujui',
|
||||
'pay': 'Bayar',
|
||||
'reject': 'Tolak',
|
||||
'current': 'Periode Saat Ini',
|
||||
'close': 'Tutup',
|
||||
'reopen': 'Buka Kembali',
|
||||
'cancel': 'Batalkan',
|
||||
deposit: 'Setor',
|
||||
withdrawal: 'Tarik',
|
||||
approve: 'Setujui',
|
||||
pay: 'Bayar',
|
||||
reject: 'Tolak',
|
||||
current: 'Periode Saat Ini',
|
||||
close: 'Tutup',
|
||||
reopen: 'Buka Kembali',
|
||||
cancel: 'Batalkan',
|
||||
'check-in': 'Check In',
|
||||
'check-out': 'Check Out',
|
||||
'by-date': 'Lihat Per Tanggal',
|
||||
'show': 'Detail',
|
||||
show: 'Detail',
|
||||
'update-system': 'Update Sistem',
|
||||
'update-homepage': 'Update Homepage',
|
||||
'update-social-media': 'Update Media Sosial',
|
||||
@ -88,7 +88,10 @@ export default function RoleCreate({ permissions }: Props) {
|
||||
<CardContent>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama Role <span className="text-destructive">*</span>
|
||||
Nama Role{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
@ -105,35 +108,48 @@ export default function RoleCreate({ permissions }: Props) {
|
||||
<CardTitle>Permission</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6">
|
||||
{Object.entries(permissions).map(([module, actions]) => (
|
||||
<div key={module} className="grid gap-3">
|
||||
<Label className="text-sm font-semibold">
|
||||
{moduleLabels[module] ?? module}
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{actions.map((action) => (
|
||||
<label
|
||||
key={`${module}.${action}`}
|
||||
className="flex items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
name="permissions[]"
|
||||
value={`${module}.${action}`}
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{actionLabels[action] ?? action}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{Object.entries(permissions).map(
|
||||
([module, actions]) => (
|
||||
<div
|
||||
key={module}
|
||||
className="grid gap-3"
|
||||
>
|
||||
<Label className="text-sm font-semibold">
|
||||
{moduleLabels[module] ??
|
||||
module}
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{actions.map(
|
||||
(action) => (
|
||||
<label
|
||||
key={`${module}.${action}`}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
name="permissions[]"
|
||||
value={`${module}.${action}`}
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{actionLabels[
|
||||
action
|
||||
] ??
|
||||
action}
|
||||
</span>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<InputError message={errors.permissions} />
|
||||
),
|
||||
)}
|
||||
<InputError
|
||||
message={errors.permissions}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-6">
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
|
||||
@ -25,41 +25,41 @@ type Props = {
|
||||
};
|
||||
|
||||
const moduleLabels: Record<string, string> = {
|
||||
'user': 'Pengguna',
|
||||
'category': 'Kategori',
|
||||
'supplier': 'Supplier',
|
||||
'customer': 'Customer',
|
||||
user: 'Pengguna',
|
||||
category: 'Kategori',
|
||||
supplier: 'Supplier',
|
||||
customer: 'Customer',
|
||||
'cash-account': 'Kas Toko',
|
||||
'expense': 'Pengeluaran',
|
||||
expense: 'Pengeluaran',
|
||||
'employee-advance': 'Kasbon',
|
||||
'payroll-period': 'Periode Gaji',
|
||||
'payroll': 'Gaji',
|
||||
payroll: 'Gaji',
|
||||
'payroll-adjustment': 'Adjustment Gaji',
|
||||
'leave-request': 'Cuti',
|
||||
'attendance': 'Absensi',
|
||||
'settings': 'Pengaturan',
|
||||
attendance: 'Absensi',
|
||||
settings: 'Pengaturan',
|
||||
};
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
'view': 'Lihat',
|
||||
'create': 'Tambah',
|
||||
'update': 'Edit',
|
||||
'delete': 'Hapus',
|
||||
view: 'Lihat',
|
||||
create: 'Tambah',
|
||||
update: 'Edit',
|
||||
delete: 'Hapus',
|
||||
'toggle-active': 'Aktif/Nonaktif',
|
||||
'reset-password': 'Reset Kata Sandi',
|
||||
'deposit': 'Setor',
|
||||
'withdrawal': 'Tarik',
|
||||
'approve': 'Setujui',
|
||||
'pay': 'Bayar',
|
||||
'reject': 'Tolak',
|
||||
'current': 'Periode Saat Ini',
|
||||
'close': 'Tutup',
|
||||
'reopen': 'Buka Kembali',
|
||||
'cancel': 'Batalkan',
|
||||
deposit: 'Setor',
|
||||
withdrawal: 'Tarik',
|
||||
approve: 'Setujui',
|
||||
pay: 'Bayar',
|
||||
reject: 'Tolak',
|
||||
current: 'Periode Saat Ini',
|
||||
close: 'Tutup',
|
||||
reopen: 'Buka Kembali',
|
||||
cancel: 'Batalkan',
|
||||
'check-in': 'Check In',
|
||||
'check-out': 'Check Out',
|
||||
'by-date': 'Lihat Per Tanggal',
|
||||
'show': 'Detail',
|
||||
show: 'Detail',
|
||||
'update-system': 'Update Sistem',
|
||||
'update-homepage': 'Update Homepage',
|
||||
'update-social-media': 'Update Media Sosial',
|
||||
@ -100,7 +100,10 @@ export default function RoleEdit({ role, permissions }: Props) {
|
||||
<CardContent>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama Role <span className="text-destructive">*</span>
|
||||
Nama Role{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
@ -118,36 +121,51 @@ export default function RoleEdit({ role, permissions }: Props) {
|
||||
<CardTitle>Permission</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6">
|
||||
{Object.entries(permissions).map(([module, actions]) => (
|
||||
<div key={module} className="grid gap-3">
|
||||
<Label className="text-sm font-semibold">
|
||||
{moduleLabels[module] ?? module}
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{actions.map((action) => (
|
||||
<label
|
||||
key={`${module}.${action}`}
|
||||
className="flex items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
name="permissions[]"
|
||||
value={`${module}.${action}`}
|
||||
defaultChecked={assignedPermissions.includes(`${module}.${action}`)}
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{actionLabels[action] ?? action}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{Object.entries(permissions).map(
|
||||
([module, actions]) => (
|
||||
<div
|
||||
key={module}
|
||||
className="grid gap-3"
|
||||
>
|
||||
<Label className="text-sm font-semibold">
|
||||
{moduleLabels[module] ??
|
||||
module}
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{actions.map(
|
||||
(action) => (
|
||||
<label
|
||||
key={`${module}.${action}`}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
name="permissions[]"
|
||||
value={`${module}.${action}`}
|
||||
defaultChecked={assignedPermissions.includes(
|
||||
`${module}.${action}`,
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{actionLabels[
|
||||
action
|
||||
] ??
|
||||
action}
|
||||
</span>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<InputError message={errors.permissions} />
|
||||
),
|
||||
)}
|
||||
<InputError
|
||||
message={errors.permissions}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-6">
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||||
</Button>
|
||||
|
||||
@ -2,12 +2,17 @@ import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import type {PaginationState, SortState} from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import type { PaginationState, SortState } from '@/components/data-table';
|
||||
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 { createRoleColumns } from './columns';
|
||||
import type {Role} from './columns';
|
||||
import {
|
||||
index as rolesIndex,
|
||||
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 = {
|
||||
roles: {
|
||||
@ -22,7 +27,10 @@ type Props = {
|
||||
export default function RoleIndex({ roles }: Props) {
|
||||
const [deleting, setDeleting] = useState<Role | null>(null);
|
||||
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 = {
|
||||
current_page: roles.current_page,
|
||||
@ -32,45 +40,64 @@ export default function RoleIndex({ roles }: Props) {
|
||||
};
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(rolesIndex.url(), {
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
rolesIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(rolesIndex.url(), {
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
rolesIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value);
|
||||
router.get(rolesIndex.url(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
}, [pagination.per_page, sort]);
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
rolesIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
sort: sort.column,
|
||||
direction: sort.direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page, sort],
|
||||
);
|
||||
|
||||
function handleSortChange(column: string, direction: 'asc' | 'desc') {
|
||||
setSort({ column, direction });
|
||||
router.get(rolesIndex.url(), {
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
}, { preserveState: true, replace: true });
|
||||
router.get(
|
||||
rolesIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
sort: column,
|
||||
direction,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
|
||||
@ -11,7 +11,13 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
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 { Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
@ -60,11 +66,17 @@ type 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 [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 [galleryKeys, setGalleryKeys] = useState<string[]>(homepage.gallery_image_keys ?? []);
|
||||
const [galleryKeys, setGalleryKeys] = useState<string[]>(
|
||||
homepage.gallery_image_keys ?? [],
|
||||
);
|
||||
const [galleryUploading, setGalleryUploading] = useState(false);
|
||||
|
||||
function addGalleryImage() {
|
||||
@ -76,7 +88,9 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
|
||||
}
|
||||
|
||||
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 (
|
||||
@ -88,10 +102,23 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
|
||||
<CardTitle>Homepage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4">
|
||||
<input type="hidden" name="hero_image_key" value={heroKey ?? ''} />
|
||||
<input type="hidden" name="about_image_key" value={aboutKey ?? ''} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="hero_image_key"
|
||||
value={heroKey ?? ''}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="about_image_key"
|
||||
value={aboutKey ?? ''}
|
||||
/>
|
||||
{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">
|
||||
@ -133,14 +160,28 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{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">
|
||||
<FileUpload
|
||||
value={key || null}
|
||||
onChange={(k) => updateGalleryKey(index, k)}
|
||||
onChange={(k) =>
|
||||
updateGalleryKey(
|
||||
index,
|
||||
k,
|
||||
)
|
||||
}
|
||||
folder="homepage/gallery"
|
||||
existingUrl={homepage.gallery_images[index] ?? null}
|
||||
onUploadingChange={setGalleryUploading}
|
||||
existingUrl={
|
||||
homepage.gallery_images[
|
||||
index
|
||||
] ?? null
|
||||
}
|
||||
onUploadingChange={
|
||||
setGalleryUploading
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@ -148,22 +189,37 @@ function HomepageSettingsTab({ homepage }: HomepageSettingsTabProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-1 text-destructive hover:text-destructive"
|
||||
onClick={() => removeGalleryImage(index)}
|
||||
onClick={() =>
|
||||
removeGalleryImage(index)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
<InputError message={errors.gallery_image_keys} />
|
||||
<InputError
|
||||
message={errors.gallery_image_keys}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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'}
|
||||
</Button>
|
||||
</div>
|
||||
@ -183,43 +239,103 @@ const sidebarTabs = [
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
<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 gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground md:hidden">Dasar</span>
|
||||
<RadioGroup name={`${prefix}[base]`} defaultValue={data.base} className="flex gap-4">
|
||||
<span className="text-xs font-medium text-muted-foreground md:hidden">
|
||||
Dasar
|
||||
</span>
|
||||
<RadioGroup
|
||||
name={`${prefix}[base]`}
|
||||
defaultValue={data.base}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="per_transaksi" id={`${prefix}-base-per_transaksi`} />
|
||||
<Label htmlFor={`${prefix}-base-per_transaksi`} className="font-normal text-xs">Per Transaksi</Label>
|
||||
<RadioGroupItem
|
||||
value="per_transaksi"
|
||||
id={`${prefix}-base-per_transaksi`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${prefix}-base-per_transaksi`}
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
Per Transaksi
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="per_produk" id={`${prefix}-base-per_produk`} />
|
||||
<Label htmlFor={`${prefix}-base-per_produk`} className="font-normal text-xs">Per Produk</Label>
|
||||
<RadioGroupItem
|
||||
value="per_produk"
|
||||
id={`${prefix}-base-per_produk`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${prefix}-base-per_produk`}
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
Per Produk
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground md:hidden">Tipe</span>
|
||||
<RadioGroup name={`${prefix}[type]`} defaultValue={data.type} onValueChange={setType} className="flex gap-4">
|
||||
<span className="text-xs font-medium text-muted-foreground md:hidden">
|
||||
Tipe
|
||||
</span>
|
||||
<RadioGroup
|
||||
name={`${prefix}[type]`}
|
||||
defaultValue={data.type}
|
||||
onValueChange={setType}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="flat" id={`${prefix}-type-flat`} />
|
||||
<Label htmlFor={`${prefix}-type-flat`} className="font-normal text-xs">Flat</Label>
|
||||
<RadioGroupItem
|
||||
value="flat"
|
||||
id={`${prefix}-type-flat`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${prefix}-type-flat`}
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
Flat
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="persentase" id={`${prefix}-type-persentase`} />
|
||||
<Label htmlFor={`${prefix}-type-persentase`} className="font-normal text-xs">Persentase</Label>
|
||||
<RadioGroupItem
|
||||
value="persentase"
|
||||
id={`${prefix}-type-persentase`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${prefix}-type-persentase`}
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
Persentase
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<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' ? (
|
||||
<RupiahInput name={`${prefix}[value]`} defaultValue={data.value} />
|
||||
<RupiahInput
|
||||
name={`${prefix}[value]`}
|
||||
defaultValue={data.value}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
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 (
|
||||
<Card>
|
||||
<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 [marketplaceTab, setMarketplaceTab] = useState<'tiktok-shop' | 'shopee'>('tiktok-shop');
|
||||
const [marketplaceTab, setMarketplaceTab] = useState<
|
||||
'tiktok-shop' | 'shopee'
|
||||
>('tiktok-shop');
|
||||
|
||||
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 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 },
|
||||
{
|
||||
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 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 = [
|
||||
{ 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: '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 },
|
||||
{
|
||||
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: '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 (
|
||||
@ -305,7 +495,10 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:space-x-12">
|
||||
<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) => (
|
||||
<Button
|
||||
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">
|
||||
{activeTab === 'sistem' && (
|
||||
<Form action={updateSystem()} options={{ preserveScroll: true }}>
|
||||
<Form
|
||||
action={updateSystem()}
|
||||
options={{ preserveScroll: true }}
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<div className="grid gap-6">
|
||||
<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">
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="app_name">
|
||||
Nama Aplikasi <span className="text-destructive">*</span>
|
||||
Nama Aplikasi{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input id="app_name" name="app_name" placeholder="Masukkan nama aplikasi" defaultValue={system.app_name} />
|
||||
<InputError message={errors.app_name} />
|
||||
<Input
|
||||
id="app_name"
|
||||
name="app_name"
|
||||
placeholder="Masukkan nama aplikasi"
|
||||
defaultValue={
|
||||
system.app_name
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.app_name
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" name="email" type="email" placeholder="Masukkan email" defaultValue={system.email} />
|
||||
<InputError message={errors.email} />
|
||||
<Label htmlFor="email">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="Masukkan email"
|
||||
defaultValue={
|
||||
system.email
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.email}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone">No. Telepon</Label>
|
||||
<PhoneNumberInput name="phone" defaultValue={system.phone} />
|
||||
<InputError message={errors.phone} />
|
||||
<Label htmlFor="phone">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<PhoneNumberInput
|
||||
name="phone"
|
||||
defaultValue={
|
||||
system.phone
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.phone}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="address">Alamat</Label>
|
||||
<Textarea id="address" name="address" placeholder="Masukkan alamat" rows={3} defaultValue={system.address} />
|
||||
<InputError message={errors.address} />
|
||||
<Label htmlFor="address">
|
||||
Alamat
|
||||
</Label>
|
||||
<Textarea
|
||||
id="address"
|
||||
name="address"
|
||||
placeholder="Masukkan alamat"
|
||||
rows={3}
|
||||
defaultValue={
|
||||
system.address
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.address}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="about_app">Tentang Aplikasi</Label>
|
||||
<Textarea id="about_app" name="about_app" placeholder="Masukkan deskripsi aplikasi" rows={4} defaultValue={system.about_app} />
|
||||
<InputError message={errors.about_app} />
|
||||
<Label htmlFor="about_app">
|
||||
Tentang Aplikasi
|
||||
</Label>
|
||||
<Textarea
|
||||
id="about_app"
|
||||
name="about_app"
|
||||
placeholder="Masukkan deskripsi aplikasi"
|
||||
rows={4}
|
||||
defaultValue={
|
||||
system.about_app
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.about_app
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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>
|
||||
)}
|
||||
@ -376,33 +640,87 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
|
||||
)}
|
||||
|
||||
{activeTab === 'media-sosial' && (
|
||||
<Form action={updateSocialMedia()} options={{ preserveScroll: true }}>
|
||||
<Form
|
||||
action={updateSocialMedia()}
|
||||
options={{ preserveScroll: true }}
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Media Sosial</CardTitle>
|
||||
<CardTitle>
|
||||
Media Sosial
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="instagram_url">Instagram</Label>
|
||||
<Input id="instagram_url" name="instagram_url" placeholder="https://instagram.com/..." defaultValue={socialMedia.instagram_url ?? ''} />
|
||||
<InputError message={errors.instagram_url} />
|
||||
<Label htmlFor="instagram_url">
|
||||
Instagram
|
||||
</Label>
|
||||
<Input
|
||||
id="instagram_url"
|
||||
name="instagram_url"
|
||||
placeholder="https://instagram.com/..."
|
||||
defaultValue={
|
||||
socialMedia.instagram_url ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.instagram_url
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="facebook_url">Facebook</Label>
|
||||
<Input id="facebook_url" name="facebook_url" placeholder="https://facebook.com/..." defaultValue={socialMedia.facebook_url ?? ''} />
|
||||
<InputError message={errors.facebook_url} />
|
||||
<Label htmlFor="facebook_url">
|
||||
Facebook
|
||||
</Label>
|
||||
<Input
|
||||
id="facebook_url"
|
||||
name="facebook_url"
|
||||
placeholder="https://facebook.com/..."
|
||||
defaultValue={
|
||||
socialMedia.facebook_url ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.facebook_url
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="tiktok_url">TikTok</Label>
|
||||
<Input id="tiktok_url" name="tiktok_url" placeholder="https://tiktok.com/..." defaultValue={socialMedia.tiktok_url ?? ''} />
|
||||
<InputError message={errors.tiktok_url} />
|
||||
<Label htmlFor="tiktok_url">
|
||||
TikTok
|
||||
</Label>
|
||||
<Input
|
||||
id="tiktok_url"
|
||||
name="tiktok_url"
|
||||
placeholder="https://tiktok.com/..."
|
||||
defaultValue={
|
||||
socialMedia.tiktok_url ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.tiktok_url
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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>
|
||||
)}
|
||||
@ -410,23 +728,58 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
|
||||
)}
|
||||
|
||||
{activeTab === 'marketplace' && (
|
||||
<Form action={updateMarketplace()} options={{ preserveScroll: true }}>
|
||||
<Form
|
||||
action={updateMarketplace()}
|
||||
options={{ preserveScroll: true }}
|
||||
>
|
||||
{({ processing }) => (
|
||||
<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">
|
||||
<TabsTrigger value="tiktok-shop">TikTok Shop</TabsTrigger>
|
||||
<TabsTrigger value="shopee">Shopee</TabsTrigger>
|
||||
<TabsTrigger value="tiktok-shop">
|
||||
TikTok Shop
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="shopee">
|
||||
Shopee
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div hidden={marketplaceTab !== 'tiktok-shop'}>
|
||||
<MarketplaceCard title="TikTok Shop" variables={tiktokShopVariables} />
|
||||
<div
|
||||
hidden={
|
||||
marketplaceTab !== 'tiktok-shop'
|
||||
}
|
||||
>
|
||||
<MarketplaceCard
|
||||
title="TikTok Shop"
|
||||
variables={tiktokShopVariables}
|
||||
/>
|
||||
</div>
|
||||
<div hidden={marketplaceTab !== 'shopee'}>
|
||||
<MarketplaceCard title="Shopee" variables={shopeeVariables} />
|
||||
<div
|
||||
hidden={marketplaceTab !== 'shopee'}
|
||||
>
|
||||
<MarketplaceCard
|
||||
title="Shopee"
|
||||
variables={shopeeVariables}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
@ -434,7 +787,10 @@ export default function AdminSettings({ system, homepage, socialMedia, marketpla
|
||||
)}
|
||||
|
||||
{activeTab === 'hr' && (
|
||||
<Form action={updateHr()} options={{ preserveScroll: true }}>
|
||||
<Form
|
||||
action={updateHr()}
|
||||
options={{ preserveScroll: true }}
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<div className="grid gap-6">
|
||||
<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">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="scheduled_check_in_time">
|
||||
Jam Masuk Kerja <span className="text-destructive">*</span>
|
||||
Jam Masuk Kerja{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input 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} />
|
||||
<Input
|
||||
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 className="grid gap-2">
|
||||
<Label htmlFor="scheduled_check_out_time">
|
||||
Jam Pulang Kerja <span className="text-destructive">*</span>
|
||||
Jam Pulang Kerja{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input 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} />
|
||||
<Input
|
||||
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 className="grid gap-2">
|
||||
<Label>
|
||||
Denda Keterlambatan <span className="text-destructive">*</span>
|
||||
Denda Keterlambatan{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput name="late_penalty_amount" defaultValue={hr.late_penalty_amount} />
|
||||
<InputError message={errors.late_penalty_amount} />
|
||||
<RupiahInput
|
||||
name="late_penalty_amount"
|
||||
defaultValue={
|
||||
hr.late_penalty_amount
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.late_penalty_amount
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Denda Bolos <span className="text-destructive">*</span>
|
||||
Denda Bolos{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<RupiahInput name="absent_penalty_amount" defaultValue={hr.absent_penalty_amount} />
|
||||
<InputError message={errors.absent_penalty_amount} />
|
||||
<RupiahInput
|
||||
name="absent_penalty_amount"
|
||||
defaultValue={
|
||||
hr.absent_penalty_amount
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.absent_penalty_amount
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@ -14,7 +14,7 @@ export default function Login() {
|
||||
<Head title="Masuk Akun" />
|
||||
|
||||
<Form
|
||||
action={store()}
|
||||
action={store()}
|
||||
resetOnSuccess={['password']}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
@ -43,7 +43,9 @@ export default function Login() {
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">
|
||||
Kata Sandi
|
||||
<span className="text-destructive">*</span>
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
<PasswordInput
|
||||
|
||||
@ -8,7 +8,10 @@ import { useEffect, useState } from 'react';
|
||||
type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unknown';
|
||||
|
||||
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' => {
|
||||
@ -36,7 +39,8 @@ const getDeniedMessage = (appName: string): string => {
|
||||
};
|
||||
|
||||
export default function Permissions() {
|
||||
const [notifications, setNotifications] = useState<PermissionStatus>('unknown');
|
||||
const [notifications, setNotifications] =
|
||||
useState<PermissionStatus>('unknown');
|
||||
const [camera, setCamera] = useState<PermissionStatus>('unknown');
|
||||
const [location, setLocation] = useState<PermissionStatus>('unknown');
|
||||
|
||||
@ -44,24 +48,32 @@ export default function Permissions() {
|
||||
|
||||
useEffect(() => {
|
||||
if ('Notification' in window) {
|
||||
if (Notification.permission === 'granted') setNotifications('granted');
|
||||
else if (Notification.permission === 'denied') setNotifications('denied');
|
||||
if (Notification.permission === 'granted')
|
||||
setNotifications('granted');
|
||||
else if (Notification.permission === 'denied')
|
||||
setNotifications('denied');
|
||||
else setNotifications('prompt');
|
||||
}
|
||||
|
||||
if (navigator.mediaDevices) {
|
||||
navigator.mediaDevices.getUserMedia({ video: true }).then((stream) => {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
setCamera('granted');
|
||||
}).catch(() => {
|
||||
if ('permissions' in navigator) {
|
||||
navigator.permissions.query({ name: 'camera' as PermissionName }).then((result) => {
|
||||
setCamera(result.state as PermissionStatus);
|
||||
}).catch(() => setCamera('denied'));
|
||||
} else {
|
||||
setCamera('denied');
|
||||
}
|
||||
});
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ video: true })
|
||||
.then((stream) => {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
setCamera('granted');
|
||||
})
|
||||
.catch(() => {
|
||||
if ('permissions' in navigator) {
|
||||
navigator.permissions
|
||||
.query({ name: 'camera' as PermissionName })
|
||||
.then((result) => {
|
||||
setCamera(result.state as PermissionStatus);
|
||||
})
|
||||
.catch(() => setCamera('denied'));
|
||||
} else {
|
||||
setCamera('denied');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setCamera('denied');
|
||||
}
|
||||
@ -71,9 +83,12 @@ export default function Permissions() {
|
||||
() => setLocation('granted'),
|
||||
() => {
|
||||
if ('permissions' in navigator) {
|
||||
navigator.permissions.query({ name: 'geolocation' }).then((result) => {
|
||||
setLocation(result.state as PermissionStatus);
|
||||
}).catch(() => setLocation('denied'));
|
||||
navigator.permissions
|
||||
.query({ name: 'geolocation' })
|
||||
.then((result) => {
|
||||
setLocation(result.state as PermissionStatus);
|
||||
})
|
||||
.catch(() => setLocation('denied'));
|
||||
} else {
|
||||
setLocation('denied');
|
||||
}
|
||||
@ -92,7 +107,13 @@ export default function Permissions() {
|
||||
return;
|
||||
}
|
||||
const result = await Notification.requestPermission();
|
||||
setNotifications(result === 'granted' ? 'granted' : result === 'denied' ? 'denied' : 'prompt');
|
||||
setNotifications(
|
||||
result === 'granted'
|
||||
? 'granted'
|
||||
: result === 'denied'
|
||||
? 'denied'
|
||||
: 'prompt',
|
||||
);
|
||||
} else {
|
||||
setNotifications('prompt');
|
||||
}
|
||||
@ -101,7 +122,9 @@ export default function Permissions() {
|
||||
const handleCamera = async (checked: boolean) => {
|
||||
if (checked) {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: true,
|
||||
});
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
setCamera('granted');
|
||||
} catch {
|
||||
@ -133,13 +156,18 @@ export default function Permissions() {
|
||||
<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">
|
||||
<Alert variant={isDenied(notifications) ? 'destructive' : 'default'}>
|
||||
<Alert
|
||||
variant={
|
||||
isDenied(notifications) ? 'destructive' : 'default'
|
||||
}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<AlertTitle>Notifikasi</AlertTitle>
|
||||
<AlertDescription>
|
||||
Izinkan aplikasi mengirimkan notifikasi push ke perangkat Anda.
|
||||
Izinkan aplikasi mengirimkan notifikasi push ke
|
||||
perangkat Anda.
|
||||
</AlertDescription>
|
||||
{isDenied(notifications) && (
|
||||
<p className="mt-1 text-sm text-destructive">
|
||||
@ -163,7 +191,8 @@ export default function Permissions() {
|
||||
<div className="flex-1">
|
||||
<AlertTitle>Kamera</AlertTitle>
|
||||
<AlertDescription>
|
||||
Izinkan aplikasi mengakses kamera perangkat Anda untuk mengambil foto atau video.
|
||||
Izinkan aplikasi mengakses kamera perangkat Anda
|
||||
untuk mengambil foto atau video.
|
||||
</AlertDescription>
|
||||
{isDenied(camera) && (
|
||||
<p className="mt-1 text-sm text-destructive">
|
||||
@ -187,7 +216,8 @@ export default function Permissions() {
|
||||
<div className="flex-1">
|
||||
<AlertTitle>Lokasi</AlertTitle>
|
||||
<AlertDescription>
|
||||
Izinkan aplikasi mengakses lokasi perangkat Anda untuk menyediakan layanan berbasis lokasi.
|
||||
Izinkan aplikasi mengakses lokasi perangkat Anda
|
||||
untuk menyediakan layanan berbasis lokasi.
|
||||
</AlertDescription>
|
||||
{isDenied(location) && (
|
||||
<p className="mt-1 text-sm text-destructive">
|
||||
|
||||
@ -33,7 +33,9 @@ type Props = {
|
||||
|
||||
export default function Profile({ user }: Props) {
|
||||
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 (
|
||||
@ -56,7 +58,10 @@ export default function Profile({ user }: Props) {
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">
|
||||
Email <span className="text-destructive">*</span>
|
||||
Email{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
@ -69,7 +74,10 @@ export default function Profile({ user }: Props) {
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">
|
||||
Username <span className="text-destructive">*</span>
|
||||
Username{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
@ -89,38 +97,71 @@ export default function Profile({ user }: Props) {
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="full_name">
|
||||
Nama Lengkap <span className="text-destructive">*</span>
|
||||
Nama Lengkap{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="full_name"
|
||||
name="full_name"
|
||||
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 className="grid gap-2">
|
||||
<Label htmlFor="phone_number">No. Telepon</Label>
|
||||
<Label htmlFor="phone_number">
|
||||
No. Telepon
|
||||
</Label>
|
||||
<PhoneNumberInput
|
||||
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 className="grid gap-2">
|
||||
<Label>Jenis Kelamin</Label>
|
||||
<RadioGroup
|
||||
name="gender"
|
||||
defaultValue={user.userProfile?.gender ?? ''}
|
||||
defaultValue={
|
||||
user.userProfile?.gender ?? ''
|
||||
}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="male" id="settings-gender-male" />
|
||||
<Label htmlFor="settings-gender-male" className="font-normal">Laki-laki</Label>
|
||||
<RadioGroupItem
|
||||
value="male"
|
||||
id="settings-gender-male"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="settings-gender-male"
|
||||
className="font-normal"
|
||||
>
|
||||
Laki-laki
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="female" id="settings-gender-female" />
|
||||
<Label htmlFor="settings-gender-female" className="font-normal">Perempuan</Label>
|
||||
<RadioGroupItem
|
||||
value="female"
|
||||
id="settings-gender-female"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="settings-gender-female"
|
||||
className="font-normal"
|
||||
>
|
||||
Perempuan
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<InputError message={errors.gender} />
|
||||
@ -133,7 +174,9 @@ export default function Profile({ user }: Props) {
|
||||
onChange={setBirthDate}
|
||||
placeholder="Pilih tanggal lahir"
|
||||
/>
|
||||
<InputError message={errors.birth_date} />
|
||||
<InputError
|
||||
message={errors.birth_date}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="address">Alamat</Label>
|
||||
@ -142,7 +185,9 @@ export default function Profile({ user }: Props) {
|
||||
name="address"
|
||||
placeholder="Masukkan alamat"
|
||||
rows={3}
|
||||
defaultValue={user.userProfile?.address ?? ''}
|
||||
defaultValue={
|
||||
user.userProfile?.address ?? ''
|
||||
}
|
||||
/>
|
||||
<InputError message={errors.address} />
|
||||
</div>
|
||||
|
||||
@ -45,7 +45,10 @@ export default function Security(props: Props) {
|
||||
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="current_password">
|
||||
Kata Sandi Saat <span className="text-destructive">*</span>
|
||||
Kata Sandi Saat{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<PasswordInput
|
||||
@ -57,12 +60,17 @@ export default function Security(props: Props) {
|
||||
placeholder="Masukkan kata sandi saat ini"
|
||||
/>
|
||||
|
||||
<InputError message={errors.current_password} />
|
||||
<InputError
|
||||
message={errors.current_password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">
|
||||
Kata Sandi Baru <span className="text-destructive">*</span>
|
||||
Kata Sandi Baru{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<PasswordInput
|
||||
@ -80,7 +88,10 @@ export default function Security(props: Props) {
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">
|
||||
Konfirmasi Kata Sandi <span className="text-destructive">*</span>
|
||||
Konfirmasi Kata Sandi{' '}
|
||||
<span className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<PasswordInput
|
||||
@ -93,7 +104,9 @@ export default function Security(props: Props) {
|
||||
/>
|
||||
|
||||
<InputError
|
||||
message={errors.password_confirmation}
|
||||
message={
|
||||
errors.password_confirmation
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
9
resources/js/types/vite-env.d.ts
vendored
9
resources/js/types/vite-env.d.ts
vendored
@ -5,9 +5,14 @@ declare module 'virtual:pwa-register' {
|
||||
immediate?: boolean;
|
||||
onNeedRefresh?: () => void;
|
||||
onOfflineReady?: () => void;
|
||||
onRegisteredSW?: (swUrl: string, registration: ServiceWorkerRegistration | undefined) => void;
|
||||
onRegisteredSW?: (
|
||||
swUrl: string,
|
||||
registration: ServiceWorkerRegistration | undefined,
|
||||
) => void;
|
||||
onRegisterError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => void;
|
||||
export function registerSW(
|
||||
options?: RegisterSWOptions,
|
||||
): (reloadPage?: boolean) => void;
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use App\Models\Category;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductPrice;
|
||||
@ -387,7 +386,7 @@ function allPriceTypes(): array
|
||||
'reject_stock' => 2,
|
||||
'retail_stock' => 4,
|
||||
'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,
|
||||
'retail_stock' => 0,
|
||||
'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,
|
||||
'retail_stock' => 0,
|
||||
'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);
|
||||
|
||||
$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');
|
||||
@ -1665,7 +1664,7 @@ function allPriceTypes(): array
|
||||
$this->actingAs($user);
|
||||
|
||||
$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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user