@@ -141,3 +141,23 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.public-page .text-slate-100,
|
||||
.public-page .text-slate-200 {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
.public-page .text-slate-300,
|
||||
.public-page .text-slate-400 {
|
||||
@apply text-muted-foreground;
|
||||
}
|
||||
|
||||
.public-page .border-white\/10 {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
.public-page .bg-white\/5 {
|
||||
@apply bg-muted/40;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ createInertiaApp({
|
||||
title: (title) => (title ? `${title} - ${appName}` : appName),
|
||||
layout: (name) => {
|
||||
switch (true) {
|
||||
case name === 'welcome':
|
||||
case name === 'welcome' ||
|
||||
name.startsWith('documentation/') ||
|
||||
name.startsWith('checkout/'):
|
||||
return null;
|
||||
case name.startsWith('auth/'):
|
||||
return AuthLayout;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
Building2,
|
||||
BookOpen,
|
||||
CreditCard,
|
||||
LayoutGrid,
|
||||
Network,
|
||||
Settings2,
|
||||
@@ -24,14 +26,19 @@ import type { NavItem } from '@/types';
|
||||
|
||||
export function AppSidebar() {
|
||||
const { auth } = usePage().props;
|
||||
|
||||
if (!auth.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const permissions = auth.permissions ?? [];
|
||||
const mainNavItems: NavItem[] = [
|
||||
{ title: 'Dashboard', href: dashboard(), icon: LayoutGrid },
|
||||
...(!auth.user.is_platform_admin &&
|
||||
auth.permissions.includes('device.view')
|
||||
...(!auth.user.is_platform_admin && permissions.includes('device.view')
|
||||
? [{ title: 'Perangkat', href: '/devices', icon: Network }]
|
||||
: []),
|
||||
...(!auth.user.is_platform_admin &&
|
||||
auth.permissions.includes('device.user.view')
|
||||
permissions.includes('device.user.view')
|
||||
? [
|
||||
{
|
||||
title: 'Users Perangkat',
|
||||
@@ -40,8 +47,22 @@ export function AppSidebar() {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: 'Dokumentasi',
|
||||
href: '/documentation/deployment',
|
||||
icon: BookOpen,
|
||||
},
|
||||
...(!auth.user.is_platform_admin && permissions.includes('license.view')
|
||||
? [
|
||||
{
|
||||
title: 'Lisensi & Tagihan',
|
||||
href: '/billing',
|
||||
icon: CreditCard,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!auth.user.is_platform_admin &&
|
||||
auth.permissions.includes('device.credential.create')
|
||||
permissions.includes('device.credential.create')
|
||||
? [
|
||||
{
|
||||
title: 'Pengaturan Perangkat',
|
||||
@@ -57,11 +78,16 @@ export function AppSidebar() {
|
||||
href: '/administration/tenants',
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
title: 'Order Lisensi',
|
||||
href: '/administration/billing-orders',
|
||||
icon: CreditCard,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!auth.user.is_platform_admin &&
|
||||
auth.user.tenant_id &&
|
||||
auth.permissions.includes('user.view')
|
||||
permissions.includes('user.view')
|
||||
? [
|
||||
{
|
||||
title: 'Users',
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
|
||||
export default function PublicThemeToggle() {
|
||||
const { resolvedAppearance, updateAppearance } = useAppearance();
|
||||
const isDark = resolvedAppearance === 'dark';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateAppearance(isDark ? 'light' : 'dark')}
|
||||
className="inline-flex size-10 items-center justify-center rounded-lg border border-border bg-background text-foreground transition hover:bg-muted"
|
||||
aria-label={isDark ? 'Gunakan mode terang' : 'Gunakan mode gelap'}
|
||||
title={isDark ? 'Mode terang' : 'Mode gelap'}
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Order = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
plan_code: string;
|
||||
gateway: string;
|
||||
status: string;
|
||||
total: number;
|
||||
currency: string;
|
||||
created_at: string;
|
||||
tenant: { name: string };
|
||||
};
|
||||
|
||||
export default function BillingOrders({
|
||||
orders,
|
||||
}: {
|
||||
orders: { data: Order[] };
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Order Lisensi" />
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Order Lisensi</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Verifikasi pembayaran manual dan pantau checkout seluruh
|
||||
tenant.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<thead className="bg-muted/50 text-left">
|
||||
<tr>
|
||||
{[
|
||||
'Tenant',
|
||||
'Paket',
|
||||
'Gateway',
|
||||
'Total',
|
||||
'Status',
|
||||
'Dibuat',
|
||||
'Aksi',
|
||||
].map((item) => (
|
||||
<th key={item} className="px-4 py-3">
|
||||
{item}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.data.map((order) => (
|
||||
<tr key={order.uuid} className="border-t">
|
||||
<td className="px-4 py-3 font-medium">
|
||||
{order.tenant.name}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{order.plan_code}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{order.gateway}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: order.currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(order.total)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{order.status}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{new Date(
|
||||
order.created_at,
|
||||
).toLocaleString('id-ID')}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{order.status === 'pending' && (
|
||||
<Form
|
||||
action={`/administration/billing-orders/${order.id}/approve`}
|
||||
method="patch"
|
||||
>
|
||||
<Button size="sm">
|
||||
Konfirmasi Bayar
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
BillingOrders.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Order Lisensi', href: '/administration/billing-orders' },
|
||||
],
|
||||
};
|
||||
@@ -11,7 +11,14 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function TenantCreate() {
|
||||
export default function TenantCreate({
|
||||
plans,
|
||||
}: {
|
||||
plans: Record<
|
||||
string,
|
||||
{ name: string; price: number; max_devices: number; max_users: number }
|
||||
>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Tambah Tenant" />
|
||||
@@ -158,6 +165,34 @@ export default function TenantCreate() {
|
||||
message={errors.deployment_domain}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Paket Lisensi</Label>
|
||||
<select
|
||||
name="plan_code"
|
||||
defaultValue="cloud_free"
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
>
|
||||
{Object.entries(plans).map(
|
||||
([code, plan]) => (
|
||||
<option
|
||||
key={code}
|
||||
value={code}
|
||||
>
|
||||
{plan.name} ·{' '}
|
||||
{plan.price === 0
|
||||
? 'Gratis'
|
||||
: `Rp${plan.price.toLocaleString('id-ID')}/bulan`}{' '}
|
||||
· {plan.max_devices}{' '}
|
||||
perangkat ·{' '}
|
||||
{plan.max_users} user
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
<InputError
|
||||
message={errors.plan_code}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex justify-end gap-3">
|
||||
|
||||
@@ -33,15 +33,29 @@ type Installation = {
|
||||
status: string;
|
||||
last_seen_at?: string;
|
||||
};
|
||||
type License = {
|
||||
plan: string;
|
||||
status: string;
|
||||
max_devices: number;
|
||||
max_users: number;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
export default function TenantEdit({
|
||||
tenant,
|
||||
users,
|
||||
installation,
|
||||
license,
|
||||
plans,
|
||||
}: {
|
||||
tenant: Tenant;
|
||||
users: User[];
|
||||
installation: Installation | null;
|
||||
license: License | null;
|
||||
plans: Record<
|
||||
string,
|
||||
{ name: string; price: number; max_devices: number; max_users: number }
|
||||
>;
|
||||
}) {
|
||||
const { flash } = usePage().props as unknown as {
|
||||
flash: { success?: string };
|
||||
@@ -150,6 +164,37 @@ export default function TenantEdit({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Paket Lisensi Aktif</Label>
|
||||
<select
|
||||
name="plan_code"
|
||||
defaultValue={
|
||||
license?.plan ?? 'cloud_free'
|
||||
}
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
>
|
||||
{Object.entries(plans).map(
|
||||
([code, plan]) => (
|
||||
<option key={code} value={code}>
|
||||
{plan.name} ·{' '}
|
||||
{plan.price === 0
|
||||
? 'Gratis'
|
||||
: `Rp${plan.price.toLocaleString('id-ID')}/bulan`}{' '}
|
||||
· {plan.max_devices}{' '}
|
||||
perangkat · {plan.max_users}{' '}
|
||||
user
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Status: {license?.status ?? 'belum ada'}
|
||||
{license?.expires_at
|
||||
? ` · sampai ${new Date(license.expires_at).toLocaleDateString('id-ID')}`
|
||||
: ''}
|
||||
</p>
|
||||
<InputError message={errors.plan_code} />
|
||||
</div>
|
||||
<div className="flex gap-3 md:col-span-2">
|
||||
<Button disabled={processing}>
|
||||
Simpan Perubahan
|
||||
|
||||
@@ -17,6 +17,7 @@ type Tenant = {
|
||||
domain?: string;
|
||||
status: string;
|
||||
}[];
|
||||
licenses: { plan: string; status: string; expires_at: string }[];
|
||||
};
|
||||
type PageLink = { url: string | null; label: string; active: boolean };
|
||||
type Props = { tenants: { data: Tenant[]; links: PageLink[]; total: number } };
|
||||
@@ -62,6 +63,7 @@ export default function TenantIndex({ tenants }: Props) {
|
||||
<th className="py-3">Tenant</th>
|
||||
<th>Status</th>
|
||||
<th>Instalasi</th>
|
||||
<th>Paket</th>
|
||||
<th>Users</th>
|
||||
<th>Dibuat</th>
|
||||
<th className="text-right">Aksi</th>
|
||||
@@ -94,6 +96,16 @@ export default function TenantIndex({ tenants }: Props) {
|
||||
: 'Nonaktif'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<div className="font-medium">
|
||||
{tenant.licenses[0]?.plan ??
|
||||
'-'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{tenant.licenses[0]?.status ??
|
||||
'belum ada'}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="font-medium">
|
||||
{tenant.installations[0]
|
||||
@@ -139,7 +151,7 @@ export default function TenantIndex({ tenants }: Props) {
|
||||
{tenants.data.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
colSpan={7}
|
||||
className="py-12 text-center text-muted-foreground"
|
||||
>
|
||||
Belum ada tenant. Buat tenant
|
||||
|
||||
@@ -8,8 +8,9 @@ import { Label } from '@/components/ui/label';
|
||||
|
||||
type Props = {
|
||||
tenant: { id: number; name: string };
|
||||
canManageRoles: boolean;
|
||||
};
|
||||
export default function UserCreate({ tenant }: Props) {
|
||||
export default function UserCreate({ tenant, canManageRoles }: Props) {
|
||||
const base = `/administration/tenants/${tenant.id}/users`;
|
||||
|
||||
return (
|
||||
@@ -57,10 +58,28 @@ export default function UserCreate({ tenant }: Props) {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/40 px-4 py-3 text-sm md:col-span-2">
|
||||
Akun baru otomatis menggunakan role baku{' '}
|
||||
<strong>Tenant User</strong>.
|
||||
</div>
|
||||
{canManageRoles ? (
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Role</Label>
|
||||
<select
|
||||
name="role"
|
||||
defaultValue="TENANT USER"
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
>
|
||||
<option value="TENANT ADMIN">
|
||||
Tenant Admin
|
||||
</option>
|
||||
<option value="TENANT USER">
|
||||
Tenant User
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-muted/40 px-4 py-3 text-sm md:col-span-2">
|
||||
Akun baru otomatis menggunakan role baku{' '}
|
||||
<strong>Tenant User</strong>.
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 md:col-span-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={base}>Batal</Link>
|
||||
|
||||
@@ -16,8 +16,13 @@ type Role = { id: number; name: string };
|
||||
type Props = {
|
||||
tenant: { id: number; name: string };
|
||||
managedUser: { id: number; name: string; email: string; roles: Role[] };
|
||||
canManageRoles: boolean;
|
||||
};
|
||||
export default function UserEdit({ tenant, managedUser }: Props) {
|
||||
export default function UserEdit({
|
||||
tenant,
|
||||
managedUser,
|
||||
canManageRoles,
|
||||
}: Props) {
|
||||
const base = `/administration/tenants/${tenant.id}/users`;
|
||||
const { flash } = usePage().props as unknown as {
|
||||
flash: { success?: string };
|
||||
@@ -65,14 +70,36 @@ export default function UserEdit({ tenant, managedUser }: Props) {
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/40 px-4 py-3 text-sm md:col-span-2">
|
||||
Role sistem:{' '}
|
||||
<strong>
|
||||
{managedUser.roles[0]?.name ??
|
||||
'Tenant User'}
|
||||
</strong>
|
||||
. Role bersifat baku dan tidak dapat diubah.
|
||||
</div>
|
||||
{canManageRoles ? (
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Role</Label>
|
||||
<select
|
||||
name="role"
|
||||
defaultValue={
|
||||
managedUser.roles[0]?.name ??
|
||||
'TENANT USER'
|
||||
}
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
>
|
||||
<option value="TENANT ADMIN">
|
||||
Tenant Admin
|
||||
</option>
|
||||
<option value="TENANT USER">
|
||||
Tenant User
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-muted/40 px-4 py-3 text-sm md:col-span-2">
|
||||
Role sistem:{' '}
|
||||
<strong>
|
||||
{managedUser.roles[0]?.name ??
|
||||
'Tenant User'}
|
||||
</strong>
|
||||
. Role bersifat baku dan tidak dapat
|
||||
diubah.
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 md:col-span-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={base}>Kembali</Link>
|
||||
|
||||
@@ -14,9 +14,14 @@ import { request } from '@/routes/password';
|
||||
type Props = {
|
||||
status?: string;
|
||||
canResetPassword: boolean;
|
||||
canRegister: boolean;
|
||||
};
|
||||
|
||||
export default function Login({ status, canResetPassword }: Props) {
|
||||
export default function Login({
|
||||
status,
|
||||
canResetPassword,
|
||||
canRegister,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Log in" />
|
||||
@@ -92,7 +97,16 @@ export default function Login({ status, canResetPassword }: Props) {
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Contact your administrator to request access.
|
||||
{canRegister ? (
|
||||
<>
|
||||
Belum memiliki tenant?{' '}
|
||||
<TextLink href="/register">
|
||||
Daftar RADIQ NDM
|
||||
</TextLink>
|
||||
</>
|
||||
) : (
|
||||
'Hubungi administrator untuk meminta akses.'
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Form, Head, Link } from '@inertiajs/react';
|
||||
import { Cloud, Server } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import InputError from '@/components/input-error';
|
||||
import PasswordInput from '@/components/password-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
type Deployment = 'managed_cloud' | 'self_hosted';
|
||||
|
||||
export default function Register({ passwordRules }: { passwordRules: string }) {
|
||||
const [deployment, setDeployment] = useState<Deployment>('managed_cloud');
|
||||
const [plan, setPlan] = useState('cloud_free');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Daftar Tenant" />
|
||||
<Form
|
||||
action="/register"
|
||||
method="post"
|
||||
className="flex flex-col gap-5"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="tenant_name">
|
||||
Nama ISP / Perusahaan
|
||||
</Label>
|
||||
<Input
|
||||
id="tenant_name"
|
||||
name="tenant_name"
|
||||
required
|
||||
autoFocus
|
||||
placeholder="Contoh: ISP Mekarmanah"
|
||||
/>
|
||||
<InputError message={errors.tenant_name} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(
|
||||
[
|
||||
[
|
||||
'managed_cloud',
|
||||
Cloud,
|
||||
'Cloud RADIQ',
|
||||
'Langsung gunakan aplikasi tanpa mengelola server.',
|
||||
],
|
||||
[
|
||||
'self_hosted',
|
||||
Server,
|
||||
'Install Local',
|
||||
'Jalankan di VPS, server, atau komputer sendiri.',
|
||||
],
|
||||
] as const
|
||||
).map(([value, Icon, title, description]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDeployment(value);
|
||||
setPlan(
|
||||
value === 'managed_cloud'
|
||||
? 'cloud_free'
|
||||
: 'local_micro',
|
||||
);
|
||||
}}
|
||||
className={`rounded-xl border p-3 text-left transition ${deployment === value ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-muted'}`}
|
||||
>
|
||||
<Icon className="mb-2 size-5 text-primary" />
|
||||
<span className="block text-sm font-semibold">
|
||||
{title}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="hidden"
|
||||
name="deployment_type"
|
||||
value={deployment}
|
||||
/>
|
||||
<InputError message={errors.deployment_type} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="plan_code">Paket lisensi</Label>
|
||||
<select
|
||||
id="plan_code"
|
||||
name="plan_code"
|
||||
value={plan}
|
||||
onChange={(event) =>
|
||||
setPlan(event.target.value)
|
||||
}
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
>
|
||||
{deployment === 'managed_cloud' ? (
|
||||
<>
|
||||
<option value="cloud_free">
|
||||
Cloud Free · Gratis · 5 perangkat ·
|
||||
2 user
|
||||
</option>
|
||||
<option value="cloud_micro">
|
||||
Cloud Micro · Rp99.000/bulan
|
||||
</option>
|
||||
<option value="cloud_isp">
|
||||
Cloud ISP · Rp249.000/bulan
|
||||
</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="local_micro">
|
||||
Local Micro · Rp69.000/bulan
|
||||
</option>
|
||||
<option value="local_isp">
|
||||
Local ISP · Rp149.000/bulan
|
||||
</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
<InputError message={errors.plan_code} />
|
||||
</div>
|
||||
{deployment === 'self_hosted' && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="deployment_domain">
|
||||
Domain / alamat server (opsional)
|
||||
</Label>
|
||||
<Input
|
||||
id="deployment_domain"
|
||||
name="deployment_domain"
|
||||
placeholder="ndm.isp-anda.co.id"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.deployment_domain}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Nama Tenant Admin</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
autoComplete="name"
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{passwordRules}
|
||||
</p>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">
|
||||
Konfirmasi Password
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={processing}>
|
||||
{processing && <Spinner />} Buat Tenant
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Sudah terdaftar?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Register.layout = {
|
||||
title: 'Daftarkan Tenant RADIQ NDM',
|
||||
description: 'Pilih Cloud RADIQ atau instalasi di server milik Anda.',
|
||||
};
|
||||
@@ -0,0 +1,247 @@
|
||||
import { Form, Head, Link } from '@inertiajs/react';
|
||||
import { CheckCircle2, Clock3, CreditCard, Server } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
type Plan = {
|
||||
code: string;
|
||||
name: string;
|
||||
deployment: string;
|
||||
price: number;
|
||||
max_devices: number;
|
||||
max_users: number;
|
||||
};
|
||||
type License = {
|
||||
plan: string;
|
||||
status: string;
|
||||
max_devices: number | null;
|
||||
max_users: number | null;
|
||||
expires_at: string | null;
|
||||
grace_until: string | null;
|
||||
};
|
||||
type Order = {
|
||||
uuid: string;
|
||||
plan_code: string;
|
||||
gateway: string;
|
||||
status: string;
|
||||
total: number;
|
||||
currency: string;
|
||||
checkout_url: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const money = (value: number) =>
|
||||
new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
|
||||
export default function BillingIndex({
|
||||
installation,
|
||||
license,
|
||||
plans,
|
||||
orders,
|
||||
gateways,
|
||||
pendingOrder,
|
||||
}: {
|
||||
installation: { deployment_type: string; status: string };
|
||||
license: License | null;
|
||||
plans: Plan[];
|
||||
orders: Order[];
|
||||
gateways: string[];
|
||||
pendingOrder: Pick<Order, 'uuid' | 'plan_code' | 'total' | 'currency'> | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Lisensi & Tagihan" />
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-primary">
|
||||
RADIQ NDM · BILLING
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold">
|
||||
Lisensi & Tagihan
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Pilih paket, checkout, dan pantau masa berlaku lisensi
|
||||
tenant.
|
||||
</p>
|
||||
</div>
|
||||
{pendingOrder && (
|
||||
<Card className="border-amber-400">
|
||||
<CardContent className="flex flex-col justify-between gap-3 pt-6 md:flex-row md:items-center">
|
||||
<div>
|
||||
<p className="font-semibold">Pembayaran masih pending</p>
|
||||
<p className="text-sm text-muted-foreground">{pendingOrder.plan_code} · {money(pendingOrder.total)}. Selesaikan order ini sebelum membuat perpanjangan baru.</p>
|
||||
</div>
|
||||
<Button asChild><Link href={`/checkout/${pendingOrder.uuid}`}>Lanjutkan Pembayaran</Link></Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">
|
||||
Paket aktif
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xl font-semibold">
|
||||
{license?.plan ?? 'Belum aktif'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Status {license?.status ?? installation.status}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">
|
||||
Batas perangkat
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xl font-semibold">
|
||||
{license?.max_devices ?? '—'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">
|
||||
Berlaku sampai
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xl font-semibold">
|
||||
{license?.plan === 'cloud_free'
|
||||
? 'Selama paket tersedia'
|
||||
: license?.expires_at
|
||||
? new Date(
|
||||
license.expires_at,
|
||||
).toLocaleDateString('id-ID')
|
||||
: '—'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold">
|
||||
Pilih paket bulanan
|
||||
</h2>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{plans.map((plan) => (
|
||||
<Card
|
||||
key={plan.code}
|
||||
className={
|
||||
license?.plan === plan.code
|
||||
? 'border-primary'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{plan.name}</CardTitle>
|
||||
{license?.plan === plan.code && (
|
||||
<CheckCircle2 className="size-5 text-emerald-500" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">
|
||||
{plan.price === 0
|
||||
? 'Gratis'
|
||||
: money(plan.price)}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{plan.price > 0 && '/bulan'}
|
||||
</span>
|
||||
</p>
|
||||
<ul className="mt-4 space-y-2 text-sm text-muted-foreground">
|
||||
<li>✓ {plan.max_devices} perangkat</li>
|
||||
<li>
|
||||
✓ {plan.max_users} user aplikasi
|
||||
</li>
|
||||
<li>✓ Users Perangkat tanpa batas</li>
|
||||
</ul>
|
||||
{plan.price > 0 && (
|
||||
<Form
|
||||
action="/billing/checkout"
|
||||
method="post"
|
||||
className="mt-5"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="plan_code"
|
||||
value={plan.code}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="gateway"
|
||||
value={gateways[0] ?? 'manual'}
|
||||
/>
|
||||
<Button className="w-full" disabled={Boolean(pendingOrder)}>
|
||||
<CreditCard className="size-4" />{' '}
|
||||
Checkout / Perpanjang
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Riwayat checkout</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{orders.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Belum ada checkout.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
key={order.uuid}
|
||||
className="flex flex-col justify-between gap-2 rounded-lg border p-3 text-sm md:flex-row md:items-center"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{order.plan_code} ·{' '}
|
||||
{money(order.total)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(
|
||||
order.created_at,
|
||||
).toLocaleString('id-ID')}{' '}
|
||||
· {order.gateway}
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Clock3 className="size-4" />
|
||||
{order.status}
|
||||
</span>
|
||||
{order.status === 'pending' && (
|
||||
<Button asChild size="sm" variant="outline"><Link href={`/checkout/${order.uuid}`}>Lanjutkan</Link></Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="rounded-xl border bg-muted/40 p-4 text-sm text-muted-foreground">
|
||||
<Server className="mr-2 inline size-4" />
|
||||
Checkout berada di Tenant Admin. Payment gateway dapat
|
||||
diganti tanpa mengubah data order atau proses lisensi.
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
BillingIndex.layout = {
|
||||
breadcrumbs: [{ title: 'Lisensi & Tagihan', href: '/billing' }],
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Form, Head, Link, usePage } from '@inertiajs/react';
|
||||
import { CheckCircle2, Clock3, CreditCard, TriangleAlert } from 'lucide-react';
|
||||
import PublicThemeToggle from '@/components/public-theme-toggle';
|
||||
|
||||
type Order = {
|
||||
uuid: string;
|
||||
plan: string;
|
||||
status: string;
|
||||
total: number;
|
||||
currency: string;
|
||||
checkout_url: string | null;
|
||||
gateway: string;
|
||||
payment_method: string | null;
|
||||
payment_methods: { code: string; name: string; fee: number }[];
|
||||
gateway_error: string | null;
|
||||
};
|
||||
|
||||
export default function CheckoutShow({ order }: { order: Order }) {
|
||||
const { errors } = usePage().props;
|
||||
const paid = order.status === 'paid';
|
||||
const total = new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: order.currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(order.total);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Checkout Lisensi" />
|
||||
<main className="min-h-screen bg-background px-5 py-8 text-foreground">
|
||||
<div className="mx-auto max-w-xl">
|
||||
<header className="mb-10 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<img
|
||||
src="/images/radiq-ndm-logo.png"
|
||||
alt="RADIQ NDM"
|
||||
className="size-12 object-contain"
|
||||
/>
|
||||
<span className="font-semibold">RADIQ NDM</span>
|
||||
</Link>
|
||||
<PublicThemeToggle />
|
||||
</header>
|
||||
<div className="rounded-2xl border bg-card p-6 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
{paid ? (
|
||||
<CheckCircle2 className="size-7 text-emerald-500" />
|
||||
) : (
|
||||
<CreditCard className="size-7 text-sky-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Checkout lisensi bulanan
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{order.plan}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 rounded-xl bg-muted/50 p-4">
|
||||
<div className="flex justify-between">
|
||||
<span>Total pembayaran</span>
|
||||
<strong>{total}</strong>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between text-sm text-muted-foreground">
|
||||
<span>Gateway</span>
|
||||
<span>{order.gateway}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between text-sm text-muted-foreground">
|
||||
<span>Status</span>
|
||||
<span>{order.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
{paid ? (
|
||||
<div className="mt-6">
|
||||
<p className="text-sm text-emerald-600">
|
||||
Pembayaran diterima dan lisensi telah
|
||||
diaktifkan.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="mt-4 block rounded-lg bg-sky-500 px-4 py-3 text-center font-semibold text-slate-950"
|
||||
>
|
||||
Login Tenant Admin
|
||||
</Link>
|
||||
</div>
|
||||
) : order.checkout_url ? (
|
||||
<div className="mt-6">
|
||||
<a
|
||||
href={order.checkout_url}
|
||||
className="block rounded-lg bg-sky-500 px-4 py-3 text-center font-semibold text-slate-950"
|
||||
>
|
||||
Lanjut Bayar dengan Duitku
|
||||
</a>
|
||||
<p className="mt-3 text-center text-xs text-muted-foreground">
|
||||
<Clock3 className="mr-1 inline size-3" />
|
||||
Status aktif setelah callback pembayaran
|
||||
terverifikasi.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-lg border p-4 text-sm">
|
||||
{order.gateway_error ? (
|
||||
<p className="text-destructive">
|
||||
<TriangleAlert className="mr-2 inline size-4" />
|
||||
{order.gateway_error}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
Pilih kanal pembayaran Duitku. Order ini tetap dapat dilanjutkan tanpa membuat order baru.
|
||||
</p>
|
||||
)}
|
||||
{errors.payment && (
|
||||
<p className="mt-2 text-destructive">{String(errors.payment)}</p>
|
||||
)}
|
||||
<Form
|
||||
action={`/checkout/${order.uuid}/retry`}
|
||||
method="post"
|
||||
className="mt-3"
|
||||
>
|
||||
{order.payment_methods.length > 0 && (
|
||||
<select
|
||||
name="payment_method"
|
||||
defaultValue={order.payment_method ?? ''}
|
||||
required
|
||||
className="mb-3 w-full rounded-md border bg-background px-3 py-2"
|
||||
>
|
||||
<option value="" disabled>Pilih kanal pembayaran</option>
|
||||
{order.payment_methods.map((method) => (
|
||||
<option key={method.code} value={method.code}>
|
||||
{method.name}{method.fee > 0 ? ` (biaya ${new Intl.NumberFormat('id-ID').format(method.fee)})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button disabled={order.payment_methods.length === 0} className="w-full rounded-md bg-sky-500 px-3 py-2 font-semibold text-slate-950 disabled:opacity-50">
|
||||
Buat dan Lanjutkan Pembayaran
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,19 @@ type Props = {
|
||||
slug: string;
|
||||
is_active: boolean;
|
||||
} | null;
|
||||
installation?: {
|
||||
deployment_type: 'managed_cloud' | 'self_hosted';
|
||||
domain: string | null;
|
||||
status: string;
|
||||
activated_at: string | null;
|
||||
} | null;
|
||||
license?: {
|
||||
plan: string;
|
||||
status: string;
|
||||
max_devices: number;
|
||||
max_users: number;
|
||||
expires_at: string | null;
|
||||
} | null;
|
||||
stats: Record<string, number | boolean>;
|
||||
};
|
||||
|
||||
@@ -30,7 +43,13 @@ const roleLabels = {
|
||||
'TENANT USER': 'Tenant User',
|
||||
};
|
||||
|
||||
export default function Dashboard({ role, tenant, stats }: Props) {
|
||||
export default function Dashboard({
|
||||
role,
|
||||
tenant,
|
||||
installation,
|
||||
license,
|
||||
stats,
|
||||
}: Props) {
|
||||
const { auth } = usePage().props;
|
||||
const isMaster = role === 'MASTER ADMIN';
|
||||
const isTenantAdmin = role === 'TENANT ADMIN';
|
||||
@@ -144,6 +163,42 @@ export default function Dashboard({ role, tenant, stats }: Props) {
|
||||
: 'Akses perangkat Anda mengikuti kebijakan yang ditetapkan oleh Tenant Admin.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{!isMaster && installation && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Deployment Tenant</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col justify-between gap-4 text-sm md:flex-row md:items-center">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{installation.deployment_type ===
|
||||
'managed_cloud'
|
||||
? 'Cloud RADIQ'
|
||||
: 'Self-hosted / Local'}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Status: {installation.status}
|
||||
{installation.domain
|
||||
? ` · ${installation.domain}`
|
||||
: ''}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Paket: {license?.plan ?? 'belum ada'} ·
|
||||
Lisensi {license?.status ?? 'belum aktif'}
|
||||
{license?.expires_at &&
|
||||
license.plan !== 'cloud_free'
|
||||
? ` · sampai ${new Date(license.expires_at).toLocaleDateString('id-ID')}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/documentation/deployment">
|
||||
Panduan Instalasi & Migrasi
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,14 +8,20 @@ import { Label } from '@/components/ui/label';
|
||||
type Item = { id: number; name: string; slug?: string };
|
||||
type Model = Item & { device_vendor_id: number; device_type_id: number };
|
||||
type Props = {
|
||||
device: (Record<string, unknown> & { connection_ports?: Record<string, number> }) | null;
|
||||
device:
|
||||
| (Record<string, unknown> & {
|
||||
connection_ports?: Record<string, number>;
|
||||
})
|
||||
| null;
|
||||
vendors: Item[];
|
||||
types: Item[];
|
||||
models: Model[];
|
||||
};
|
||||
export default function DeviceForm({ device, vendors, types, models }: Props) {
|
||||
const editing = !!device;
|
||||
const initialVendor = vendors.find((vendor) => vendor.id === Number(device?.device_vendor_id));
|
||||
const initialVendor = vendors.find(
|
||||
(vendor) => vendor.id === Number(device?.device_vendor_id),
|
||||
);
|
||||
const [vendorSlug, setVendorSlug] = useState(initialVendor?.slug ?? '');
|
||||
const fields = [
|
||||
['name', 'Nama Perangkat'],
|
||||
@@ -25,12 +31,20 @@ export default function DeviceForm({ device, vendors, types, models }: Props) {
|
||||
const protocolProfiles: Record<string, Array<[string, string, number]>> = {
|
||||
mikrotik: [['routeros_api', 'Port RouterOS API', 8728]],
|
||||
zte: [['ssh', 'Port SSH', 22]],
|
||||
hsgq: [['ssh', 'Port SSH', 22], ['telnet', 'Port Telnet', 23]],
|
||||
hisfocus: [['telnet', 'Port Telnet', 23], ['web_http', 'Port WebGUI HTTP', 80]],
|
||||
hsgq: [
|
||||
['ssh', 'Port SSH', 22],
|
||||
['telnet', 'Port Telnet', 23],
|
||||
],
|
||||
hisfocus: [
|
||||
['telnet', 'Port Telnet', 23],
|
||||
['web_http', 'Port WebGUI HTTP', 80],
|
||||
],
|
||||
};
|
||||
const flexibleProtocols: Array<[string, string, number]> = [
|
||||
['ssh', 'Port SSH', 22], ['telnet', 'Port Telnet', 23],
|
||||
['web_http', 'Port WebGUI HTTP', 80], ['snmp', 'Port SNMP', 161],
|
||||
['ssh', 'Port SSH', 22],
|
||||
['telnet', 'Port Telnet', 23],
|
||||
['web_http', 'Port WebGUI HTTP', 80],
|
||||
['snmp', 'Port SNMP', 161],
|
||||
['vendor_api', 'Port Vendor API', 0],
|
||||
];
|
||||
const protocols = protocolProfiles[vendorSlug] ?? flexibleProtocols;
|
||||
@@ -84,7 +98,11 @@ export default function DeviceForm({ device, vendors, types, models }: Props) {
|
||||
)}
|
||||
required
|
||||
onChange={(event) => {
|
||||
const selected = vendors.find((vendor) => vendor.id === Number(event.target.value));
|
||||
const selected = vendors.find(
|
||||
(vendor) =>
|
||||
vendor.id ===
|
||||
Number(event.target.value),
|
||||
);
|
||||
setVendorSlug(selected?.slug ?? '');
|
||||
}}
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
@@ -139,24 +157,57 @@ export default function DeviceForm({ device, vendors, types, models }: Props) {
|
||||
</select>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 md:col-span-2">
|
||||
<h3 className="font-medium">Koneksi Management</h3>
|
||||
<p className="mb-4 text-sm text-muted-foreground">Kosongkan port jika protokol tersebut tidak tersedia pada perangkat.</p>
|
||||
<InputError message={errors.connection_ports} />
|
||||
<h3 className="font-medium">
|
||||
Koneksi Management
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Kosongkan port jika protokol tersebut
|
||||
tidak tersedia pada perangkat.
|
||||
</p>
|
||||
<InputError
|
||||
message={errors.connection_ports}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{protocols.map(([protocol, label, defaultPort]) => (
|
||||
<div key={`${vendorSlug}-${protocol}`} className="grid gap-2">
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
name={`connection_ports[${protocol}]`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
placeholder={defaultPort ? String(defaultPort) : 'Port custom'}
|
||||
defaultValue={device?.connection_ports?.[protocol] ?? ''}
|
||||
/>
|
||||
<InputError message={errors[`connection_ports.${protocol}`]} />
|
||||
</div>
|
||||
))}
|
||||
{protocols.map(
|
||||
([
|
||||
protocol,
|
||||
label,
|
||||
defaultPort,
|
||||
]) => (
|
||||
<div
|
||||
key={`${vendorSlug}-${protocol}`}
|
||||
className="grid gap-2"
|
||||
>
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
name={`connection_ports[${protocol}]`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
placeholder={
|
||||
defaultPort
|
||||
? String(
|
||||
defaultPort,
|
||||
)
|
||||
: 'Port custom'
|
||||
}
|
||||
defaultValue={
|
||||
device
|
||||
?.connection_ports?.[
|
||||
protocol
|
||||
] ?? ''
|
||||
}
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors[
|
||||
`connection_ports.${protocol}`
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
@@ -173,26 +224,76 @@ export default function DeviceForm({ device, vendors, types, models }: Props) {
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>Username Awal</Label>
|
||||
<Input name="initial_username" required autoComplete="off" />
|
||||
<InputError message={errors.initial_username} />
|
||||
<Input
|
||||
name="initial_username"
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
<InputError
|
||||
message={
|
||||
errors.initial_username
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Password Awal</Label>
|
||||
<Input name="initial_password" type="password" autoComplete="new-password" />
|
||||
<p className="text-xs text-muted-foreground">Boleh kosong untuk perangkat MikroTik yang masih memakai password kosong.</p>
|
||||
<InputError message={errors.initial_password} />
|
||||
<Input
|
||||
name="initial_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Boleh kosong untuk perangkat
|
||||
MikroTik yang masih memakai
|
||||
password kosong.
|
||||
</p>
|
||||
<InputError
|
||||
message={
|
||||
errors.initial_password
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Enable Password (opsional)</Label>
|
||||
<Input name="initial_enable_password" type="password" autoComplete="new-password" />
|
||||
<p className="text-xs text-muted-foreground">Khusus perangkat CLI seperti ZTE C300/C320. Default pabrik tertentu adalah zxr10; isi sesuai kondisi perangkat.</p>
|
||||
<InputError message={errors.initial_enable_password} />
|
||||
<Label>
|
||||
Enable Password (opsional)
|
||||
</Label>
|
||||
<Input
|
||||
name="initial_enable_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Khusus perangkat CLI seperti ZTE
|
||||
C300/C320. Default pabrik
|
||||
tertentu adalah zxr10; isi
|
||||
sesuai kondisi perangkat.
|
||||
</p>
|
||||
<InputError
|
||||
message={
|
||||
errors.initial_enable_password
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-start gap-3 rounded-lg border p-4 md:col-span-2">
|
||||
<input type="checkbox" name="remove_legacy_users_on_activation" value="1" className="mt-1" />
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remove_legacy_users_on_activation"
|
||||
value="1"
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">Hapus user lama setelah koneksi pertama</span>
|
||||
<span className="block text-sm text-muted-foreground">Dijalankan hanya setelah Base User RADIQ berhasil dibuat dan diuji login. User grup full tetap dipertahankan untuk mencegah perangkat terkunci.</span>
|
||||
<span className="block font-medium">
|
||||
Hapus user lama setelah
|
||||
koneksi pertama
|
||||
</span>
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
Dijalankan hanya setelah
|
||||
Base User RADIQ berhasil
|
||||
dibuat dan diuji login. User
|
||||
grup full tetap
|
||||
dipertahankan untuk mencegah
|
||||
perangkat terkunci.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function DeviceIndex({
|
||||
<Button variant="outline">Filter</Button>
|
||||
</form>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[980px] table-fixed text-sm [&_td]:align-middle [&_td]:p-3 [&_th]:p-3">
|
||||
<table className="w-full min-w-[980px] table-fixed text-sm [&_td]:p-3 [&_td]:align-middle [&_th]:p-3">
|
||||
<colgroup>
|
||||
<col className="w-[20%]" />
|
||||
<col className="w-[18%]" />
|
||||
@@ -202,10 +202,25 @@ export default function DeviceIndex({
|
||||
'Model belum ditentukan'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="break-words p-3">
|
||||
<div>{device.management_address}</div>
|
||||
<td className="p-3 break-words">
|
||||
<div>
|
||||
{device.management_address}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{Object.entries(device.connection_ports ?? { [device.connection_type]: device.management_port }).map(([protocol, port]) => `${protocol}:${port}`).join(' · ')}
|
||||
{Object.entries(
|
||||
device.connection_ports ?? {
|
||||
[device.connection_type]:
|
||||
device.management_port,
|
||||
},
|
||||
)
|
||||
.map(
|
||||
([
|
||||
protocol,
|
||||
port,
|
||||
]) =>
|
||||
`${protocol}:${port}`,
|
||||
)
|
||||
.join(' · ')}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -48,11 +48,14 @@ export default function DeviceSettings({
|
||||
const [revealing, setRevealing] = useState(false);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
}
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const revealBasePassword = async () => {
|
||||
setRevealing(true);
|
||||
@@ -60,29 +63,47 @@ clearTimeout(hideTimer.current);
|
||||
setRevealedPassword('');
|
||||
|
||||
try {
|
||||
const csrf = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
const csrf =
|
||||
document.querySelector<HTMLMetaElement>(
|
||||
'meta[name="csrf-token"]',
|
||||
)?.content ?? '';
|
||||
const response = await fetch('/device-settings/reveal-password', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': csrf },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrf,
|
||||
},
|
||||
body: JSON.stringify({ current_password: loginPassword }),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.errors?.current_password?.[0] ?? result.message ?? 'Password tidak dapat ditampilkan.');
|
||||
throw new Error(
|
||||
result.errors?.current_password?.[0] ??
|
||||
result.message ??
|
||||
'Password tidak dapat ditampilkan.',
|
||||
);
|
||||
}
|
||||
|
||||
setRevealedPassword(result.password);
|
||||
setLoginPassword('');
|
||||
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
}
|
||||
clearTimeout(hideTimer.current);
|
||||
}
|
||||
|
||||
hideTimer.current = setTimeout(() => setRevealedPassword(''), 30000);
|
||||
hideTimer.current = setTimeout(
|
||||
() => setRevealedPassword(''),
|
||||
30000,
|
||||
);
|
||||
} catch (error) {
|
||||
setRevealError(error instanceof Error ? error.message : 'Password tidak dapat ditampilkan.');
|
||||
setRevealError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Password tidak dapat ditampilkan.',
|
||||
);
|
||||
} finally {
|
||||
setRevealing(false);
|
||||
}
|
||||
@@ -106,9 +127,7 @@ clearTimeout(hideTimer.current);
|
||||
{({ processing, errors }) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Template Base User Tenant
|
||||
</CardTitle>
|
||||
<CardTitle>Template Base User Tenant</CardTitle>
|
||||
<CardDescription>
|
||||
Password disimpan terenkripsi dan tidak
|
||||
pernah ditampilkan kembali.
|
||||
@@ -200,31 +219,58 @@ clearTimeout(hideTimer.current);
|
||||
<div className="rounded-lg border bg-muted/40 p-4 text-sm md:col-span-2">
|
||||
<strong>Group yang dibuat:</strong>{' '}
|
||||
RADIQ-READ, RADIQ-WRITE, dan RADIQ-NOC tanpa
|
||||
permission <code>policy</code>, serta
|
||||
group bawaan full untuk akun provisioning
|
||||
permission <code>policy</code>, serta group
|
||||
bawaan full untuk akun provisioning
|
||||
terpusat.
|
||||
</div>
|
||||
{hasPassword && (
|
||||
<div className="grid gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950 md:col-span-2">
|
||||
<div>
|
||||
<strong>Lihat Base Password</strong>
|
||||
<p>Masukkan password login Tenant Admin. Password perangkat akan disembunyikan otomatis setelah 30 detik.</p>
|
||||
<p>
|
||||
Masukkan password login Tenant
|
||||
Admin. Password perangkat akan
|
||||
disembunyikan otomatis setelah
|
||||
30 detik.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<Input
|
||||
type="password"
|
||||
value={loginPassword}
|
||||
onChange={(event) => setLoginPassword(event.target.value)}
|
||||
onChange={(event) =>
|
||||
setLoginPassword(
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="Password login Tenant Admin"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<Button type="button" variant="outline" disabled={revealing || !loginPassword} onClick={revealBasePassword}>
|
||||
{revealing ? 'Memverifikasi...' : 'Lihat Password'}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={
|
||||
revealing || !loginPassword
|
||||
}
|
||||
onClick={revealBasePassword}
|
||||
>
|
||||
{revealing
|
||||
? 'Memverifikasi...'
|
||||
: 'Lihat Password'}
|
||||
</Button>
|
||||
</div>
|
||||
{revealError && <p className="text-destructive">{revealError}</p>}
|
||||
{revealError && (
|
||||
<p className="text-destructive">
|
||||
{revealError}
|
||||
</p>
|
||||
)}
|
||||
{revealedPassword && (
|
||||
<Input value={revealedPassword} readOnly className="font-mono" aria-label="Base Password" />
|
||||
<Input
|
||||
value={revealedPassword}
|
||||
readOnly
|
||||
className="font-mono"
|
||||
aria-label="Base Password"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -241,21 +287,36 @@ clearTimeout(hideTimer.current);
|
||||
<CardHeader>
|
||||
<CardTitle>Status Rotasi Base User</CardTitle>
|
||||
<CardDescription>
|
||||
Perangkat offline akan dicoba kembali oleh scheduler.
|
||||
Vendor tanpa driver ditandai unsupported.
|
||||
Perangkat offline akan dicoba kembali oleh
|
||||
scheduler. Vendor tanpa driver ditandai unsupported.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{devices.map((device) => (
|
||||
<div key={device.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3 text-sm">
|
||||
<div
|
||||
key={device.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3 text-sm"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{device.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{device.vendor.name} · {device.base_sync_message ?? 'Belum pernah disinkronkan'}</div>
|
||||
<div className="font-medium">
|
||||
{device.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{device.vendor.name} ·{' '}
|
||||
{device.base_sync_message ??
|
||||
'Belum pernah disinkronkan'}
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-full bg-muted px-2 py-1 text-xs uppercase">{device.base_sync_status}</span>
|
||||
<span className="rounded-full bg-muted px-2 py-1 text-xs uppercase">
|
||||
{device.base_sync_status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{devices.length === 0 && <p className="py-4 text-center text-sm text-muted-foreground">Belum ada perangkat.</p>}
|
||||
{devices.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
Belum ada perangkat.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Form
|
||||
|
||||
@@ -47,7 +47,16 @@ export default function DeviceShow({ device, credentials }: Props) {
|
||||
['Tipe', device.type.name],
|
||||
['Model', device.model?.name ?? '-'],
|
||||
['Alamat Management', device.management_address],
|
||||
['Koneksi', Object.entries(device.connection_ports ?? { [device.connection_type]: device.management_port }).map(([protocol, port]) => `${protocol}: ${port}`).join(' · ')],
|
||||
[
|
||||
'Koneksi',
|
||||
Object.entries(
|
||||
device.connection_ports ?? {
|
||||
[device.connection_type]: device.management_port,
|
||||
},
|
||||
)
|
||||
.map(([protocol, port]) => `${protocol}: ${port}`)
|
||||
.join(' · '),
|
||||
],
|
||||
['Hostname', device.hostname ?? '-'],
|
||||
['Serial Number', device.serial_number ?? '-'],
|
||||
['Versi RouterOS', device.software_version ?? '-'],
|
||||
@@ -89,50 +98,48 @@ export default function DeviceShow({ device, credentials }: Props) {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{['mikrotik', 'zte', 'hsgq', 'hisfocus'].includes(device.vendor.slug) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Aktivasi {device.vendor.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="text-sm">
|
||||
<p>
|
||||
Status:{' '}
|
||||
<strong className="uppercase">
|
||||
{device.activation_status}
|
||||
</strong>
|
||||
{['mikrotik', 'zte', 'hsgq', 'hisfocus'].includes(
|
||||
device.vendor.slug,
|
||||
) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aktivasi {device.vendor.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="text-sm">
|
||||
<p>
|
||||
Status:{' '}
|
||||
<strong className="uppercase">
|
||||
{device.activation_status}
|
||||
</strong>
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{device.activation_message ??
|
||||
'Aktivasi akan menguji koneksi dan membuat Base User management dengan aman.'}
|
||||
</p>
|
||||
{errors.activation && (
|
||||
<p className="mt-2 text-destructive">
|
||||
{errors.activation}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{device.activation_message ??
|
||||
'Aktivasi akan menguji koneksi dan membuat Base User management dengan aman.'}
|
||||
</p>
|
||||
{errors.activation && (
|
||||
<p className="mt-2 text-destructive">
|
||||
{errors.activation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{auth.permissions.includes(
|
||||
'device.connect',
|
||||
) && (
|
||||
<Form
|
||||
action={`/devices/${device.id}/activate`}
|
||||
method="post"
|
||||
>
|
||||
{({ processing }) => (
|
||||
<Button disabled={processing}>
|
||||
{processing
|
||||
? 'Mengaktifkan...'
|
||||
: 'Cek & Aktifkan Perangkat'}
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{auth.permissions.includes('device.connect') && (
|
||||
<Form
|
||||
action={`/devices/${device.id}/activate`}
|
||||
method="post"
|
||||
>
|
||||
{({ processing }) => (
|
||||
<Button disabled={processing}>
|
||||
{processing
|
||||
? 'Mengaktifkan...'
|
||||
: 'Cek & Aktifkan Perangkat'}
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informasi</CardTitle>
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Cloud,
|
||||
Database,
|
||||
Download,
|
||||
KeyRound,
|
||||
Network,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import PublicThemeToggle from '@/components/public-theme-toggle';
|
||||
|
||||
const Code = ({ children }: { children: string }) => (
|
||||
<pre className="mt-3 overflow-x-auto rounded-xl border border-white/10 bg-black/40 p-4 text-xs leading-6 text-sky-100">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
);
|
||||
|
||||
const Step = ({
|
||||
number,
|
||||
children,
|
||||
}: {
|
||||
number: number;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<li className="flex gap-3 text-sm leading-6 text-slate-300">
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-sky-500/15 text-xs font-semibold text-sky-300">
|
||||
{number}
|
||||
</span>
|
||||
<div>{children}</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
const vendorRows = [
|
||||
[
|
||||
'MikroTik RouterOS 6/7',
|
||||
'RouterOS API',
|
||||
'8728 / 8729',
|
||||
'Port API wajib diisi. TLS mengikuti pengaturan tenant.',
|
||||
],
|
||||
[
|
||||
'OLT ZTE C300/C320',
|
||||
'SSH',
|
||||
'22',
|
||||
'Mendukung login, enable, dan privilege perangkat. Telnet tidak dimatikan oleh RADIQ.',
|
||||
],
|
||||
[
|
||||
'OLT HSGQ',
|
||||
'SSH + Telnet',
|
||||
'22 + 23',
|
||||
'Port dapat dibuat custom. Perintah tertentu dijalankan melalui Telnet.',
|
||||
],
|
||||
[
|
||||
'OLT Hisfocus/Hioso',
|
||||
'Telnet + WebGUI HTTP',
|
||||
'23 + 80',
|
||||
'Port custom didukung, misalnya Telnet 8183 dan WebGUI 8182.',
|
||||
],
|
||||
];
|
||||
|
||||
export default function DeploymentDocumentation() {
|
||||
return (
|
||||
<>
|
||||
<Head title="Dokumentasi RADIQ NDM" />
|
||||
<main className="public-page min-h-screen bg-background text-foreground transition-colors">
|
||||
<div className="mx-auto max-w-6xl px-5 py-8 md:px-8">
|
||||
<header className="mb-12 flex flex-wrap items-center justify-between gap-4">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<img
|
||||
src="/images/radiq-ndm-logo.png"
|
||||
className="size-12 object-contain"
|
||||
alt="RADIQ NDM"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold">RADIQ NDM</p>
|
||||
<p className="text-xs text-sky-300">
|
||||
Dokumentasi aplikasi
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2">
|
||||
<PublicThemeToggle />
|
||||
<Link href="/login" className="px-4 py-2 text-sm">
|
||||
Login
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="rounded-lg bg-sky-500 px-4 py-2 text-sm font-semibold text-slate-950"
|
||||
>
|
||||
Daftar Tenant
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-8 lg:grid-cols-[1fr_280px]">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-sky-400">
|
||||
PANDUAN RESMI APLIKASI
|
||||
</p>
|
||||
<h1 className="mt-3 max-w-3xl text-4xl font-bold tracking-tight md:text-5xl">
|
||||
Menjalankan RADIQ NDM di Cloud atau server milik
|
||||
tenant.
|
||||
</h1>
|
||||
<p className="mt-5 max-w-3xl leading-7 text-slate-300">
|
||||
RADIQ NDM memusatkan pengelolaan akun akses
|
||||
perangkat jaringan. Tenant Admin dapat membuat,
|
||||
mengubah, menonaktifkan, dan menghapus Users
|
||||
Perangkat pada banyak perangkat tanpa membuka
|
||||
perangkat satu per satu.
|
||||
</p>
|
||||
</div>
|
||||
<aside className="rounded-2xl border border-white/10 bg-white/5 p-5 text-sm">
|
||||
<p className="font-semibold">Isi dokumentasi</p>
|
||||
<div className="mt-3 grid gap-2 text-slate-400">
|
||||
{[
|
||||
'Pilih deployment',
|
||||
'Persyaratan server',
|
||||
'Instalasi Windows/Linux',
|
||||
'Worker wajib',
|
||||
'Konfigurasi perangkat',
|
||||
'Keamanan dan backup',
|
||||
'Migrasi Cloud ke Local',
|
||||
].map((item) => (
|
||||
<span key={item}>• {item}</span>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="mt-14">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
1. Pilih lokasi deployment
|
||||
</h2>
|
||||
<div className="mt-5 grid gap-5 md:grid-cols-2">
|
||||
<article className="rounded-2xl border border-sky-400/25 bg-sky-400/5 p-6">
|
||||
<Cloud className="size-7 text-sky-400" />
|
||||
<h3 className="mt-4 text-xl font-semibold">
|
||||
Cloud RADIQ
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">
|
||||
Tenant langsung menggunakan server yang
|
||||
dikelola RADIQ. Cocok jika tidak ingin
|
||||
memelihara PHP, PostgreSQL, web server,
|
||||
worker, backup aplikasi, dan pembaruan
|
||||
sistem sendiri.
|
||||
</p>
|
||||
<p className="mt-4 text-xs text-slate-400">
|
||||
Saat registrasi pilih{' '}
|
||||
<strong className="text-slate-200">
|
||||
Cloud RADIQ
|
||||
</strong>
|
||||
. Instalasi dibuat dengan status aktif.
|
||||
</p>
|
||||
</article>
|
||||
<article className="rounded-2xl border border-white/10 bg-white/5 p-6">
|
||||
<Server className="size-7 text-sky-400" />
|
||||
<h3 className="mt-4 text-xl font-semibold">
|
||||
Self-hosted / Local
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">
|
||||
Aplikasi dan database berjalan di VPS,
|
||||
server, komputer, atau laptop tenant. Tenant
|
||||
tetap tercatat dan lisensinya tetap dikelola
|
||||
Master Admin RADIQ.
|
||||
</p>
|
||||
<p className="mt-4 text-xs text-slate-400">
|
||||
Saat registrasi pilih{' '}
|
||||
<strong className="text-slate-200">
|
||||
Install Local
|
||||
</strong>
|
||||
. Instalasi berstatus pending sampai
|
||||
pemasangan dan lisensi selesai.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-14">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
2. Persyaratan self-hosted
|
||||
</h2>
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
[
|
||||
Terminal,
|
||||
'PHP 8.3',
|
||||
'Ekstensi Laravel, PDO PostgreSQL, OpenSSL, cURL, Mbstring, XML, dan ZIP.',
|
||||
],
|
||||
[
|
||||
Database,
|
||||
'PostgreSQL',
|
||||
'Database dan user khusus RADIQ NDM dengan akses ke database tersebut.',
|
||||
],
|
||||
[
|
||||
Server,
|
||||
'Web & build tools',
|
||||
'Nginx/Apache/IIS, Composer, Node.js dan NPM.',
|
||||
],
|
||||
[
|
||||
Network,
|
||||
'Akses jaringan',
|
||||
'Server harus dapat menjangkau IP dan port management perangkat.',
|
||||
],
|
||||
].map(([Icon, title, text]) => {
|
||||
const ItemIcon = Icon as typeof Server;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={title as string}
|
||||
className="rounded-xl border border-white/10 p-5"
|
||||
>
|
||||
<ItemIcon className="size-5 text-sky-400" />
|
||||
<h3 className="mt-3 font-semibold">
|
||||
{title as string}
|
||||
</h3>
|
||||
<p className="mt-2 text-xs leading-5 text-slate-400">
|
||||
{text as string}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-5 rounded-xl border border-amber-400/20 bg-amber-400/5 p-4 text-sm leading-6 text-amber-100">
|
||||
<AlertTriangle className="mr-2 inline size-4" />
|
||||
Document root web server wajib menunjuk ke folder{' '}
|
||||
<code>public</code>, bukan ke root project.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-14">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
3. Instalasi self-hosted
|
||||
</h2>
|
||||
<a
|
||||
href="/downloads/self-hosted"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-sky-400/30 px-4 py-2 text-sm font-semibold text-sky-300 hover:bg-sky-400/10"
|
||||
>
|
||||
<Download className="size-4" /> Unduh paket
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-5 rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="font-semibold">
|
||||
Konfigurasi minimum file .env
|
||||
</h3>
|
||||
<Code>{`APP_NAME="RADIQ NDM"
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://ndm.domain-tenant.id
|
||||
DEPLOYMENT_MODE=self_hosted
|
||||
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=radiq_ndm
|
||||
DB_USERNAME=radiq_ndm
|
||||
DB_PASSWORD=PASSWORD_DATABASE
|
||||
|
||||
QUEUE_CONNECTION=database
|
||||
CACHE_STORE=database
|
||||
SESSION_DRIVER=database
|
||||
RADIQ_LICENSE_SERVER_URL=https://alamat-control-plane-radiq
|
||||
RADIQ_LICENSE_PUBLIC_KEY=PUBLIC_KEY_DARI_RADIQ`}</Code>
|
||||
<p className="mt-3 text-xs leading-5 text-slate-400">
|
||||
URL server lisensi dan public key diberikan
|
||||
Master Admin RADIQ. Private signing key tidak
|
||||
boleh berada pada server tenant.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-5 lg:grid-cols-2">
|
||||
<article className="rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Windows
|
||||
</h3>
|
||||
<ol className="mt-5 space-y-4">
|
||||
<Step number={1}>
|
||||
Ekstrak paket dan buka PowerShell pada
|
||||
folder aplikasi.
|
||||
</Step>
|
||||
<Step number={2}>
|
||||
Jalankan installer dengan PHP 8.3:
|
||||
<Code>{`powershell -ExecutionPolicy Bypass -File scripts/install-self-hosted.ps1 -Php C:\\php83\\php.exe`}</Code>
|
||||
</Step>
|
||||
<Step number={3}>
|
||||
Installer membuat <code>.env</code>,
|
||||
lalu berhenti agar koneksi database
|
||||
dapat diisi. Ketik <code>LANJUT</code>{' '}
|
||||
setelah selesai.
|
||||
</Step>
|
||||
<Step number={4}>
|
||||
Isi identitas ISP dan Tenant Admin.
|
||||
Password minimal 12 karakter dengan
|
||||
huruf besar/kecil, angka, dan simbol.
|
||||
</Step>
|
||||
</ol>
|
||||
</article>
|
||||
<article className="rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold">Linux</h3>
|
||||
<ol className="mt-5 space-y-4">
|
||||
<Step number={1}>
|
||||
Ekstrak paket dan berikan akses folder
|
||||
serta <code>storage</code> kepada user
|
||||
service.
|
||||
</Step>
|
||||
<Step number={2}>
|
||||
Jalankan installer:
|
||||
<Code>{`PHP_BIN=/usr/bin/php8.3 bash scripts/install-self-hosted.sh`}</Code>
|
||||
</Step>
|
||||
<Step number={3}>
|
||||
Lengkapi <code>.env</code>, lalu ketik{' '}
|
||||
<code>LANJUT</code>. Installer
|
||||
menjalankan Composer, migration, seeder,
|
||||
pembuatan tenant, build frontend, dan
|
||||
optimasi.
|
||||
</Step>
|
||||
<Step number={4}>
|
||||
Konfigurasikan Nginx/Apache dengan HTTPS
|
||||
dan document root ke folder{' '}
|
||||
<code>public</code>.
|
||||
</Step>
|
||||
</ol>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-14">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
4. Proses background yang wajib hidup
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300">
|
||||
CRUD Users Perangkat dikirim ke queue. Jika
|
||||
perangkat offline, assignment menjadi pending dan
|
||||
scheduler akan mencoba kembali. Jalankan kedua
|
||||
proses sebagai Windows Service, Task Scheduler,
|
||||
systemd, atau Supervisor.
|
||||
</p>
|
||||
<Code>{`# Worker sinkronisasi perangkat
|
||||
php artisan queue:work --queue=default --tries=1 --timeout=60
|
||||
|
||||
# Scheduler pekerjaan pending dan tugas berkala
|
||||
php artisan schedule:work`}</Code>
|
||||
<p className="mt-3 text-xs text-slate-400">
|
||||
Pada Windows, ganti <code>php</code> dengan{' '}
|
||||
<code>C:\php83\php.exe</code>. Setelah deployment
|
||||
jalankan <code>php artisan optimize:clear</code> dan{' '}
|
||||
<code>php artisan queue:restart</code>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-14">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
5. Konfigurasi koneksi perangkat
|
||||
</h2>
|
||||
<div className="mt-5 overflow-x-auto rounded-2xl border border-white/10">
|
||||
<table className="w-full min-w-[760px] text-left text-sm">
|
||||
<thead className="bg-white/5 text-slate-200">
|
||||
<tr>
|
||||
{[
|
||||
'Vendor',
|
||||
'Protokol',
|
||||
'Port default',
|
||||
'Catatan',
|
||||
].map((title) => (
|
||||
<th
|
||||
key={title}
|
||||
className="px-4 py-3 font-semibold"
|
||||
>
|
||||
{title}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vendorRows.map((row) => (
|
||||
<tr
|
||||
key={row[0]}
|
||||
className="border-t border-white/10"
|
||||
>
|
||||
{row.map((cell) => (
|
||||
<td
|
||||
key={cell}
|
||||
className="px-4 py-3 align-top leading-6 text-slate-400"
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-5 rounded-xl border border-white/10 p-5 text-sm leading-6 text-slate-300">
|
||||
<strong>Aturan port:</strong> form perangkat berubah
|
||||
mengikuti vendor. Isi hanya port protokol yang
|
||||
tersedia. Port kosong berarti protokol tersebut
|
||||
tidak digunakan. Firewall server RADIQ harus dapat
|
||||
mencapai management network.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-14 grid gap-5 lg:grid-cols-2">
|
||||
<article className="rounded-2xl border border-white/10 p-6">
|
||||
<Users className="size-6 text-sky-400" />
|
||||
<h2 className="mt-4 text-xl font-semibold">
|
||||
6. Cara kerja Users Perangkat
|
||||
</h2>
|
||||
<ol className="mt-5 space-y-3">
|
||||
<Step number={1}>
|
||||
Tenant Admin mengatur Base User dan Base
|
||||
Password.
|
||||
</Step>
|
||||
<Step number={2}>
|
||||
Tambahkan perangkat dengan credential awal,
|
||||
lalu Cek & Aktifkan.
|
||||
</Step>
|
||||
<Step number={3}>
|
||||
Buat Users Perangkat, pilih perangkat tujuan
|
||||
dan group akses.
|
||||
</Step>
|
||||
<Step number={4}>
|
||||
Perangkat online langsung disinkronkan;
|
||||
perangkat offline berstatus pending.
|
||||
</Step>
|
||||
</ol>
|
||||
<p className="mt-4 text-xs leading-5 text-slate-400">
|
||||
Role aplikasi tetap: Master Admin, Tenant Admin,
|
||||
dan Tenant User. Tenant Admin hanya mengelola
|
||||
Tenant User miliknya.
|
||||
</p>
|
||||
</article>
|
||||
<article className="rounded-2xl border border-white/10 p-6">
|
||||
<KeyRound className="size-6 text-sky-400" />
|
||||
<h2 className="mt-4 text-xl font-semibold">
|
||||
7. Credential dan encryption key
|
||||
</h2>
|
||||
<div className="mt-4 space-y-3 text-sm leading-6 text-slate-300">
|
||||
<p>
|
||||
Password perangkat dienkripsi dengan tenant
|
||||
encryption key. Tenant key dibungkus kembali
|
||||
oleh application key server.
|
||||
</p>
|
||||
<p>
|
||||
Tenant Admin dapat membuat key otomatis atau
|
||||
manual. Rotasi key mengenkripsi ulang
|
||||
credential yang tersimpan.
|
||||
</p>
|
||||
<p>
|
||||
Melihat Base Password wajib memasukkan
|
||||
password login Tenant Admin dan dibatasi
|
||||
rate limit.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg bg-red-400/5 p-3 text-xs leading-5 text-red-100">
|
||||
Kehilangan APP_KEY atau encryption key tanpa
|
||||
backup yang benar dapat membuat credential tidak
|
||||
dapat dipulihkan.
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="mt-14">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
8. Backup dan pemeliharaan
|
||||
</h2>
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-3">
|
||||
{[
|
||||
[
|
||||
'Database PostgreSQL',
|
||||
'Backup seluruh database termasuk tenant, assignment, queue, dan credential terenkripsi.',
|
||||
],
|
||||
[
|
||||
'File rahasia',
|
||||
'Backup .env dan APP_KEY secara terenkripsi, terpisah dari database dan dengan akses terbatas.',
|
||||
],
|
||||
[
|
||||
'Uji pemulihan',
|
||||
'Lakukan restore drill pada server terisolasi. Backup yang belum diuji belum dapat dianggap aman.',
|
||||
],
|
||||
].map(([title, text]) => (
|
||||
<article
|
||||
key={title}
|
||||
className="rounded-xl border border-white/10 p-5"
|
||||
>
|
||||
<ShieldCheck className="size-5 text-emerald-400" />
|
||||
<h3 className="mt-3 font-semibold">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-2 text-xs leading-5 text-slate-400">
|
||||
{text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<Code>{`# Contoh backup PostgreSQL
|
||||
pg_dump -Fc -U radiq_ndm -d radiq_ndm -f radiq_ndm.backup
|
||||
|
||||
# Maintenance setelah update aplikasi
|
||||
php artisan migrate --force
|
||||
php artisan optimize
|
||||
php artisan queue:restart`}</Code>
|
||||
</section>
|
||||
|
||||
<section className="mt-14">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
9. Migrasi Cloud ke Local
|
||||
</h2>
|
||||
<div className="mt-4 rounded-xl border border-amber-400/20 bg-amber-400/5 p-4 text-sm leading-6 text-amber-100">
|
||||
<AlertTriangle className="mr-2 inline size-4" />
|
||||
Versi saat ini belum menyediakan ekspor–impor tenant
|
||||
otomatis. Migrasi harus dijadwalkan bersama Master
|
||||
Admin agar credential terenkripsi dan encryption key
|
||||
dipindahkan dengan aman.
|
||||
</div>
|
||||
<ol className="mt-5 space-y-4">
|
||||
{[
|
||||
'Tenant Admin mengajukan migrasi dan jadwal maintenance.',
|
||||
'Master Admin menyiapkan instalasi self-hosted dan lisensi tenant.',
|
||||
'Pasang RADIQ NDM local dengan APP_KEY baru, tetapi jangan aktifkan worker perangkat.',
|
||||
'Pindahkan data tenant dan re-enkripsi secret melalui prosedur migrasi; jangan mengekspor password sebagai plaintext.',
|
||||
'Verifikasi user, perangkat, Users Perangkat, assignment, port vendor, dan status lisensi.',
|
||||
'Hentikan worker Cloud sebelum menyalakan worker Local agar perintah tidak berjalan dua kali.',
|
||||
'Uji satu perangkat per vendor, kemudian selesaikan cutover.',
|
||||
].map((item, index) => (
|
||||
<Step key={item} number={index + 1}>
|
||||
{item}
|
||||
</Step>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="mt-14 rounded-2xl bg-sky-500 p-7 text-slate-950">
|
||||
<h2 className="text-2xl font-bold">
|
||||
Mulai dengan deployment yang sesuai
|
||||
</h2>
|
||||
<p className="mt-2 max-w-2xl">
|
||||
Cloud cocok untuk penggunaan cepat. Self-hosted
|
||||
cocok ketika management perangkat hanya dapat
|
||||
diakses dari jaringan internal tenant.
|
||||
</p>
|
||||
<Link
|
||||
href="/register"
|
||||
className="mt-5 inline-flex items-center gap-2 rounded-lg bg-slate-950 px-5 py-3 font-semibold text-white"
|
||||
>
|
||||
Daftar Tenant <ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+175
-12
@@ -1,5 +1,6 @@
|
||||
import { Head, Link, usePage } from '@inertiajs/react';
|
||||
import { Cloud, Network, Server, ShieldCheck } from 'lucide-react';
|
||||
import { BookOpen, Cloud, Network, Server, ShieldCheck } from 'lucide-react';
|
||||
import PublicThemeToggle from '@/components/public-theme-toggle';
|
||||
import { dashboard, login } from '@/routes';
|
||||
|
||||
export default function Welcome() {
|
||||
@@ -30,7 +31,7 @@ export default function Welcome() {
|
||||
return (
|
||||
<>
|
||||
<Head title="Network Device Management" />
|
||||
<main className="min-h-screen bg-slate-950 text-white">
|
||||
<main className="min-h-screen bg-background text-foreground transition-colors">
|
||||
<div className="mx-auto flex min-h-screen max-w-6xl flex-col px-6">
|
||||
<header className="flex items-center justify-between py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -43,17 +44,34 @@ export default function Welcome() {
|
||||
<div className="font-semibold tracking-wide">
|
||||
RADIQ NDM
|
||||
</div>
|
||||
<div className="text-xs text-sky-300">
|
||||
<div className="text-xs text-sky-600 dark:text-sky-300">
|
||||
Network Device Management
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={auth.user ? dashboard() : login()}
|
||||
className="rounded-lg bg-sky-500 px-5 py-2.5 text-sm font-semibold text-slate-950 hover:bg-sky-400"
|
||||
>
|
||||
{auth.user ? 'Dashboard' : 'Login'}
|
||||
</Link>
|
||||
<nav className="flex items-center gap-3">
|
||||
<PublicThemeToggle />
|
||||
<Link
|
||||
href="/documentation/deployment"
|
||||
className="hidden text-sm text-muted-foreground hover:text-foreground sm:inline"
|
||||
>
|
||||
Dokumentasi
|
||||
</Link>
|
||||
{!auth.user && (
|
||||
<Link
|
||||
href="/register"
|
||||
className="rounded-lg border border-sky-500/40 px-4 py-2.5 text-sm font-semibold text-sky-600 hover:bg-sky-400/10 dark:text-sky-300"
|
||||
>
|
||||
Daftar Tenant
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={auth.user ? dashboard() : login()}
|
||||
className="rounded-lg bg-sky-500 px-5 py-2.5 text-sm font-semibold text-slate-950 hover:bg-sky-400"
|
||||
>
|
||||
{auth.user ? 'Dashboard' : 'Login'}
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<section className="grid flex-1 items-center gap-12 py-16 lg:grid-cols-2">
|
||||
<div>
|
||||
@@ -63,12 +81,27 @@ export default function Welcome() {
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl">
|
||||
Kelola akses perangkat jaringan dengan aman.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-xl text-lg leading-8 text-slate-300">
|
||||
<p className="mt-6 max-w-xl text-lg leading-8 text-muted-foreground">
|
||||
Saat karyawan berpindah tugas atau keluar, akun
|
||||
perangkat dapat dinonaktifkan secara terpusat
|
||||
tanpa membuka setiap router, OLT, switch, atau
|
||||
access point satu per satu.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/register"
|
||||
className="rounded-lg bg-sky-500 px-5 py-3 font-semibold text-slate-950 hover:bg-sky-400"
|
||||
>
|
||||
Mulai & Pilih Deployment
|
||||
</Link>
|
||||
<Link
|
||||
href="/documentation/deployment"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border px-5 py-3 font-semibold hover:bg-muted"
|
||||
>
|
||||
<BookOpen className="size-4" /> Panduan
|
||||
Instalasi & Migrasi
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
@@ -82,20 +115,150 @@ export default function Welcome() {
|
||||
{features.map((feature) => (
|
||||
<article
|
||||
key={feature.title}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 p-5"
|
||||
className="rounded-2xl border border-border bg-card p-5"
|
||||
>
|
||||
<feature.icon className="mb-4 size-6 text-sky-400" />
|
||||
<h2 className="font-semibold">
|
||||
{feature.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{feature.text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<Pricing />
|
||||
<Roadmap />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const plans = [
|
||||
{
|
||||
name: 'Cloud Free',
|
||||
price: 'Gratis',
|
||||
deployment: 'Cloud RADIQ',
|
||||
devices: '5 perangkat',
|
||||
users: '2 user aplikasi',
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
name: 'Cloud Micro',
|
||||
price: 'Rp99.000',
|
||||
deployment: 'Cloud RADIQ',
|
||||
devices: '25 perangkat',
|
||||
users: '5 user aplikasi',
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
name: 'Cloud ISP',
|
||||
price: 'Rp249.000',
|
||||
deployment: 'Cloud RADIQ',
|
||||
devices: '100 perangkat',
|
||||
users: '15 user aplikasi',
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
name: 'Local Micro',
|
||||
price: 'Rp69.000',
|
||||
deployment: 'Self-hosted',
|
||||
devices: '50 perangkat',
|
||||
users: '5 user aplikasi',
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
name: 'Local ISP',
|
||||
price: 'Rp149.000',
|
||||
deployment: 'Self-hosted',
|
||||
devices: '250 perangkat',
|
||||
users: '20 user aplikasi',
|
||||
featured: false,
|
||||
},
|
||||
];
|
||||
|
||||
function Pricing() {
|
||||
return (
|
||||
<section id="harga" className="py-16">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<p className="text-sm font-semibold text-sky-600 dark:text-sky-400">
|
||||
HARGA EARLY ACCESS
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-bold">
|
||||
Lisensi bulanan yang ramah untuk ISP lokal
|
||||
</h2>
|
||||
<p className="mt-3 text-muted-foreground">
|
||||
Tidak ada kontrak tahunan wajib. User perangkat tidak
|
||||
dihitung sebagai user aplikasi.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-9 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
{plans.map((plan) => (
|
||||
<article
|
||||
key={plan.name}
|
||||
className={`relative rounded-2xl border p-5 ${plan.featured ? 'border-sky-500 bg-sky-500/5 shadow-lg shadow-sky-500/10' : 'border-border bg-card'}`}
|
||||
>
|
||||
{plan.featured && (
|
||||
<span className="absolute -top-3 left-4 rounded-full bg-sky-500 px-3 py-1 text-xs font-semibold text-slate-950">
|
||||
Paling sesuai ISP
|
||||
</span>
|
||||
)}
|
||||
<p className="text-sm font-semibold text-sky-600 dark:text-sky-400">
|
||||
{plan.deployment}
|
||||
</p>
|
||||
<h3 className="mt-2 text-xl font-bold">{plan.name}</h3>
|
||||
<p className="mt-4 text-2xl font-bold">
|
||||
{plan.price}
|
||||
{plan.price !== 'Gratis' && (
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
/bulan
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<ul className="mt-5 space-y-2 text-sm text-muted-foreground">
|
||||
<li>✓ Hingga {plan.devices}</li>
|
||||
<li>✓ Hingga {plan.users}</li>
|
||||
<li>✓ Users Perangkat tanpa batas</li>
|
||||
<li>✓ Update aplikasi</li>
|
||||
<li>✓ Dukungan dasar</li>
|
||||
</ul>
|
||||
<Link
|
||||
href="/register"
|
||||
className="mt-6 block rounded-lg bg-sky-500 px-4 py-2.5 text-center text-sm font-semibold text-slate-950 hover:bg-sky-400"
|
||||
>
|
||||
Pilih paket
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-5 text-center text-xs leading-5 text-muted-foreground">
|
||||
Harga belum termasuk PPN, VPS/server tenant, instalasi khusus,
|
||||
atau migrasi manual. Harga dapat dievaluasi seiring penambahan
|
||||
fitur dengan pemberitahuan sebelumnya.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Roadmap() {
|
||||
return (
|
||||
<section className="pb-16">
|
||||
<div className="rounded-2xl border border-border bg-muted/40 p-7">
|
||||
<p className="text-sm font-semibold text-sky-600 dark:text-sky-400">
|
||||
ROADMAP · BELUM TERMASUK FITUR AKTIF
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl font-bold">
|
||||
RADIQ NDM akan tumbuh bersama kebutuhan operator jaringan.
|
||||
</h2>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
Harga early-access saat ini mencerminkan aplikasi yang masih
|
||||
sekitar 30% dari visi produk. Tahap berikutnya direncanakan
|
||||
mencakup monitoring terpusat, topologi perangkat, dashboard
|
||||
NOC, alert status perangkat, histori performa, backup
|
||||
konfigurasi, dan dukungan vendor tambahan.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user