production #1
|
|
@ -26,6 +26,14 @@ public function index(Request $request): Response
|
|||
$packages = Package::query()
|
||||
->latest('id')
|
||||
->paginate(25);
|
||||
|
||||
return Inertia::render('Admin/Packages/Index', [
|
||||
'packages' => $packages,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
// Minimal lookups for create form (active only)
|
||||
$profiles = \App\Models\SmsProfile::query()
|
||||
->where('active', true)
|
||||
|
|
@ -58,8 +66,7 @@ public function index(Request $request): Response
|
|||
})
|
||||
->values();
|
||||
|
||||
return Inertia::render('Admin/Packages/Index', [
|
||||
'packages' => $packages,
|
||||
return Inertia::render('Admin/Packages/Create', [
|
||||
'profiles' => $profiles,
|
||||
'senders' => $senders,
|
||||
'templates' => $templates,
|
||||
|
|
@ -312,7 +319,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||
$request->validate([
|
||||
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||
'q' => ['nullable', 'string'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
|
||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||
'only_mobile' => ['nullable', 'boolean'],
|
||||
'only_validated' => ['nullable', 'boolean'],
|
||||
|
|
@ -323,7 +330,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||
]);
|
||||
|
||||
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
||||
$perPage = (int) ($request->input('per_page') ?? 25);
|
||||
|
||||
|
||||
$query = Contract::query()
|
||||
->with([
|
||||
|
|
@ -390,9 +397,9 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||
});
|
||||
}
|
||||
|
||||
$contracts = $query->paginate($perPage);
|
||||
$contracts = $query->get();
|
||||
|
||||
$data = collect($contracts->items())->map(function (Contract $contract) use ($selector) {
|
||||
$data = collect($contracts)->map(function (Contract $contract) use ($selector) {
|
||||
$person = $contract->clientCase?->person;
|
||||
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
|
||||
$phone = $selected['phone'];
|
||||
|
|
@ -431,13 +438,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
|||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'current_page' => $contracts->currentPage(),
|
||||
'last_page' => $contracts->lastPage(),
|
||||
'per_page' => $contracts->perPage(),
|
||||
'total' => $contracts->total(),
|
||||
],
|
||||
'data' => $data
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -311,6 +311,9 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||
'action_id' => 'exists:\App\Models\Action,id',
|
||||
'decision_id' => 'exists:\App\Models\Decision,id',
|
||||
'contract_uuid' => 'nullable|uuid',
|
||||
'contract_uuids' => 'nullable|array',
|
||||
'contract_uuids.*' => 'uuid',
|
||||
'create_for_all_contracts' => 'nullable|boolean',
|
||||
'phone_view' => 'nullable|boolean',
|
||||
'send_auto_mail' => 'sometimes|boolean',
|
||||
'attachment_document_ids' => 'sometimes|array',
|
||||
|
|
@ -318,61 +321,102 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||
]);
|
||||
|
||||
$isPhoneView = $attributes['phone_view'] ?? false;
|
||||
$createForAll = $attributes['create_for_all_contracts'] ?? false;
|
||||
$contractUuids = $attributes['contract_uuids'] ?? [];
|
||||
|
||||
// Map contract_uuid to contract_id within the same client case, if provided
|
||||
$contractId = null;
|
||||
if (! empty($attributes['contract_uuid'])) {
|
||||
// Determine which contracts to process
|
||||
$contractIds = [];
|
||||
if ($createForAll && !empty($contractUuids)) {
|
||||
// Get all contract IDs from the provided UUIDs
|
||||
$contracts = Contract::withTrashed()
|
||||
->whereIn('uuid', $contractUuids)
|
||||
->where('client_case_id', $clientCase->id)
|
||||
->get();
|
||||
$contractIds = $contracts->pluck('id')->toArray();
|
||||
} elseif (!empty($contractUuids) && isset($contractUuids[0])) {
|
||||
// Single contract mode
|
||||
$contract = Contract::withTrashed()
|
||||
->where('uuid', $contractUuids[0])
|
||||
->where('client_case_id', $clientCase->id)
|
||||
->first();
|
||||
if ($contract) {
|
||||
$contractIds = [$contract->id];
|
||||
}
|
||||
} elseif (!empty($attributes['contract_uuid'])) {
|
||||
// Legacy single contract_uuid support
|
||||
$contract = Contract::withTrashed()
|
||||
->where('uuid', $attributes['contract_uuid'])
|
||||
->where('client_case_id', $clientCase->id)
|
||||
->first();
|
||||
if ($contract) {
|
||||
// Archived contracts are allowed: link activity regardless of active flag
|
||||
$contractId = $contract->id;
|
||||
$contractIds = [$contract->id];
|
||||
}
|
||||
}
|
||||
|
||||
// Create activity
|
||||
$row = $clientCase->activities()->create([
|
||||
'due_date' => $attributes['due_date'] ?? null,
|
||||
'amount' => $attributes['amount'] ?? null,
|
||||
'note' => $attributes['note'] ?? null,
|
||||
'action_id' => $attributes['action_id'],
|
||||
'decision_id' => $attributes['decision_id'],
|
||||
'contract_id' => $contractId,
|
||||
]);
|
||||
|
||||
if ($isPhoneView && $contractId) {
|
||||
$fieldJob = $contract->fieldJobs()
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->where('assigned_user_id', \Auth::id())
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($fieldJob) {
|
||||
$fieldJob->update([
|
||||
'added_activity' => true,
|
||||
'last_activity' => $row->created_at,
|
||||
]);
|
||||
|
||||
}
|
||||
// If no contracts specified, create a single activity without contract
|
||||
if (empty($contractIds)) {
|
||||
$contractIds = [null];
|
||||
}
|
||||
|
||||
logger()->info('Activity successfully inserted', $attributes);
|
||||
$createdActivities = [];
|
||||
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
||||
|
||||
// Auto mail dispatch (best-effort)
|
||||
try {
|
||||
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
||||
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
|
||||
// Filter attachments to those belonging to the selected contract
|
||||
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
|
||||
->filter()
|
||||
->map(fn ($v) => (int) $v)
|
||||
->values();
|
||||
$validAttachmentIds = collect();
|
||||
if ($attachmentIds->isNotEmpty() && $contractId) {
|
||||
$validAttachmentIds = Document::query()
|
||||
// Disable auto mail if creating activities for multiple contracts
|
||||
if ($sendFlag && count($contractIds) > 1) {
|
||||
$sendFlag = false;
|
||||
logger()->info('Auto mail disabled: multiple contracts selected', ['contract_count' => count($contractIds)]);
|
||||
}
|
||||
|
||||
foreach ($contractIds as $contractId) {
|
||||
// Create activity
|
||||
$row = $clientCase->activities()->create([
|
||||
'due_date' => $attributes['due_date'] ?? null,
|
||||
'amount' => $attributes['amount'] ?? null,
|
||||
'note' => $attributes['note'] ?? null,
|
||||
'action_id' => $attributes['action_id'],
|
||||
'decision_id' => $attributes['decision_id'],
|
||||
'contract_id' => $contractId,
|
||||
]);
|
||||
|
||||
$createdActivities[] = $row;
|
||||
|
||||
if ($isPhoneView && $contractId) {
|
||||
$contract = Contract::find($contractId);
|
||||
if ($contract) {
|
||||
$fieldJob = $contract->fieldJobs()
|
||||
->whereNull('completed_at')
|
||||
->whereNull('cancelled_at')
|
||||
->where('assigned_user_id', \Auth::id())
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($fieldJob) {
|
||||
$fieldJob->update([
|
||||
'added_activity' => true,
|
||||
'last_activity' => $row->created_at,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger()->info('Activity successfully inserted', array_merge($attributes, ['contract_id' => $contractId]));
|
||||
|
||||
// Auto mail dispatch (best-effort)
|
||||
try {
|
||||
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
|
||||
// Filter attachments to those belonging to the selected contract
|
||||
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
|
||||
->filter()
|
||||
->map(fn ($v) => (int) $v)
|
||||
->values();
|
||||
$validAttachmentIds = collect();
|
||||
if ($attachmentIds->isNotEmpty() && $contractId) {
|
||||
$validAttachmentIds = Document::query()
|
||||
->where('documentable_type', Contract::class)
|
||||
->where('documentable_id', $contractId)
|
||||
->whereIn('id', $attachmentIds)
|
||||
->pluck('id');
|
||||
$validAttachmentIds = Document::query()
|
||||
->where('documentable_type', Contract::class)
|
||||
->where('documentable_id', $contractId)
|
||||
->whereIn('id', $attachmentIds)
|
||||
|
|
@ -383,19 +427,25 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
|||
]);
|
||||
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
|
||||
// If template requires contract and user attempted to send, surface a validation message
|
||||
return back()->with('warning', 'Email not queued: required contract is missing for the selected template.');
|
||||
logger()->warning('Email not queued: required contract is missing for the selected template.');
|
||||
}
|
||||
if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) {
|
||||
return back()->with('warning', 'Email not queued: no eligible client emails to receive auto mails.');
|
||||
logger()->warning('Email not queued: no eligible client emails to receive auto mails.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Do not fail activity creation due to mailing issues
|
||||
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$activityCount = count($createdActivities);
|
||||
$successMessage = $activityCount > 1
|
||||
? "Successfully created {$activityCount} activities!"
|
||||
: 'Successfully created activity!';
|
||||
|
||||
// Stay on the current page (desktop or phone) instead of forcing a redirect to the desktop route.
|
||||
// Use 303 to align with Inertia's recommended POST/Redirect/GET behavior.
|
||||
return back(303)->with('success', 'Successful created!')->with('flash_method', 'POST');
|
||||
return back(303)->with('success', $successMessage)->with('flash_method', 'POST');
|
||||
} catch (QueryException $e) {
|
||||
logger()->error('Database error occurred:', ['error' => $e->getMessage()]);
|
||||
|
||||
|
|
|
|||
|
|
@ -462,6 +462,17 @@ function keyOf(row) {
|
|||
return row[props.rowKey];
|
||||
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
// Expose methods for parent component
|
||||
defineExpose({
|
||||
clearSelection: () => {
|
||||
table.resetRowSelection();
|
||||
rowSelection.value = {};
|
||||
},
|
||||
getSelectedRows: () => {
|
||||
return Object.keys(rowSelection.value).filter((key) => rowSelection.value[key]);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
|
|
@ -1,178 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { SidebarProps } from "@/Components/ui/sidebar";
|
||||
|
||||
import {
|
||||
AudioWaveform,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Command,
|
||||
Frame,
|
||||
GalleryVerticalEnd,
|
||||
Map,
|
||||
PieChart,
|
||||
Settings2,
|
||||
SquareTerminal,
|
||||
} from "lucide-vue-next";
|
||||
import NavMain from "@/Components/app/ui/layout/NavMain.vue";
|
||||
import NavProjects from "@/Components/app/ui/layout/NavProjects.vue";
|
||||
import NavUser from "@/Components/app/ui/layout/NavUser.vue";
|
||||
import TeamSwitcher from "@/Components/app/ui/layout/TeamSwitcher.vue";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||
collapsible: "icon",
|
||||
});
|
||||
|
||||
// This is sample data.
|
||||
const data = {
|
||||
user: {
|
||||
name: "shadcn",
|
||||
email: "m@example.com",
|
||||
avatar: "/avatars/shadcn.jpg",
|
||||
},
|
||||
teams: [
|
||||
{
|
||||
name: "Acme Inc",
|
||||
logo: GalleryVerticalEnd,
|
||||
plan: "Enterprise",
|
||||
},
|
||||
{
|
||||
name: "Acme Corp.",
|
||||
logo: AudioWaveform,
|
||||
plan: "Startup",
|
||||
},
|
||||
{
|
||||
name: "Evil Corp.",
|
||||
logo: Command,
|
||||
plan: "Free",
|
||||
},
|
||||
],
|
||||
navMain: [
|
||||
{
|
||||
title: "Playground",
|
||||
url: "#",
|
||||
icon: SquareTerminal,
|
||||
isActive: true,
|
||||
items: [
|
||||
{
|
||||
title: "History",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Starred",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Models",
|
||||
url: "#",
|
||||
icon: Bot,
|
||||
items: [
|
||||
{
|
||||
title: "Genesis",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Explorer",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Quantum",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Documentation",
|
||||
url: "#",
|
||||
icon: BookOpen,
|
||||
items: [
|
||||
{
|
||||
title: "Introduction",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Get Started",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Tutorials",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Changelog",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: "#",
|
||||
icon: Settings2,
|
||||
items: [
|
||||
{
|
||||
title: "General",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Billing",
|
||||
url: "#",
|
||||
},
|
||||
{
|
||||
title: "Limits",
|
||||
url: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
name: "Design Engineering",
|
||||
url: "#",
|
||||
icon: Frame,
|
||||
},
|
||||
{
|
||||
name: "Sales & Marketing",
|
||||
url: "#",
|
||||
icon: PieChart,
|
||||
},
|
||||
{
|
||||
name: "Travel",
|
||||
url: "#",
|
||||
icon: Map,
|
||||
},
|
||||
],
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidebar v-bind="props">
|
||||
<SidebarHeader>
|
||||
<TeamSwitcher :teams="data.teams" />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain :items="data.navMain" />
|
||||
<NavProjects :projects="data.projects" />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser :user="data.user" />
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</template>
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { LucideIcon } from "lucide-vue-next";
|
||||
import { ChevronRight } from "lucide-vue-next";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/Components/ui/collapsible";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
defineProps<{
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<Collapsible
|
||||
v-for="item in items"
|
||||
:key="item.title"
|
||||
as-child
|
||||
:default-open="item.isActive"
|
||||
class="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuButton :tooltip="item.title">
|
||||
<component :is="item.icon" v-if="item.icon" />
|
||||
<span>{{ item.title }}</span>
|
||||
<ChevronRight
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
|
||||
<SidebarMenuSubButton as-child>
|
||||
<a :href="subItem.url">
|
||||
<span>{{ subItem.title }}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</template>
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { LucideIcon } from "lucide-vue-next";
|
||||
import { Folder, Forward, MoreHorizontal, Trash2 } from "lucide-vue-next";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
defineProps<{
|
||||
projects: {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
}[];
|
||||
}>();
|
||||
|
||||
const { isMobile } = useSidebar();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroup class="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Projects</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in projects" :key="item.name">
|
||||
<SidebarMenuButton as-child>
|
||||
<a :href="item.url">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.name }}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuAction show-on-hover>
|
||||
<MoreHorizontal />
|
||||
<span class="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-48 rounded-lg"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
:align="isMobile ? 'end' : 'start'"
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<Folder class="text-muted-foreground" />
|
||||
<span>View Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Forward class="text-muted-foreground" />
|
||||
<span>Share Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Trash2 class="text-muted-foreground" />
|
||||
<span>Delete Project</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton class="text-sidebar-foreground/70">
|
||||
<MoreHorizontal class="text-sidebar-foreground/70" />
|
||||
<span>More</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</template>
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
ChevronsUpDown,
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/Components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
const props = defineProps<{
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const { isMobile } = useSidebar();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<AvatarFallback class="rounded-lg"> CN </AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{{ user.name }}</span>
|
||||
<span class="truncate text-xs">{{ user.email }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
align="end"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<AvatarFallback class="rounded-lg"> CN </AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ user.name }}</span>
|
||||
<span class="truncate text-xs">{{ user.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Sparkles />
|
||||
Upgrade to Pro
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<BadgeCheck />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CreditCard />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Bell />
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { Component } from "vue";
|
||||
|
||||
import { ChevronsUpDown, Plus } from "lucide-vue-next";
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/Components/ui/sidebar";
|
||||
|
||||
const props = defineProps<{
|
||||
teams: {
|
||||
name: string;
|
||||
logo: Component;
|
||||
plan: string;
|
||||
}[];
|
||||
}>();
|
||||
|
||||
const { isMobile } = useSidebar();
|
||||
const activeTeam = ref(props.teams[0]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
>
|
||||
<component :is="activeTeam.logo" class="size-4" />
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">
|
||||
{{ activeTeam.name }}
|
||||
</span>
|
||||
<span class="truncate text-xs">{{ activeTeam.plan }}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
align="start"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="text-xs text-muted-foreground">
|
||||
Teams
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
v-for="(team, index) in teams"
|
||||
:key="team.name"
|
||||
class="gap-2 p-2"
|
||||
@click="activeTeam = team"
|
||||
>
|
||||
<div class="flex size-6 items-center justify-center rounded-sm border">
|
||||
<component :is="team.logo" class="size-3.5 shrink-0" />
|
||||
</div>
|
||||
{{ team.name }}
|
||||
<DropdownMenuShortcut>⌘{{ index + 1 }}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem class="gap-2 p-2">
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
|
||||
>
|
||||
<Plus class="size-4" />
|
||||
</div>
|
||||
<div class="font-medium text-muted-foreground">Add team</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</template>
|
||||
800
resources/js/Pages/Admin/Packages/Create.vue
Normal file
800
resources/js/Pages/Admin/Packages/Create.vue
Normal file
|
|
@ -0,0 +1,800 @@
|
|||
<script setup>
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { Link, router, useForm } from "@inertiajs/vue3";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/Components/ui/tabs";
|
||||
import {
|
||||
PackageIcon,
|
||||
PhoneIcon,
|
||||
UsersIcon,
|
||||
SearchIcon,
|
||||
SaveIcon,
|
||||
ArrowLeftIcon,
|
||||
FilterIcon,
|
||||
CalendarIcon,
|
||||
CheckCircle2Icon,
|
||||
XCircleIcon,
|
||||
BadgeCheckIcon,
|
||||
} from "lucide-vue-next";
|
||||
import { fmtDateDMY } from "@/Utilities/functions";
|
||||
|
||||
const props = defineProps({
|
||||
profiles: { type: Array, default: () => [] },
|
||||
senders: { type: Array, default: () => [] },
|
||||
templates: { type: Array, default: () => [] },
|
||||
segments: { type: Array, default: () => [] },
|
||||
clients: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const creatingFromContracts = ref(false);
|
||||
|
||||
const createMode = ref("numbers"); // 'numbers' | 'contracts'
|
||||
const form = useForm({
|
||||
type: "sms",
|
||||
name: "",
|
||||
description: "",
|
||||
profile_id: null,
|
||||
sender_id: null,
|
||||
template_id: null,
|
||||
delivery_report: false,
|
||||
body: "",
|
||||
numbers: "", // one per line
|
||||
});
|
||||
|
||||
const filteredSenders = computed(() => {
|
||||
if (!form.profile_id) return props.senders;
|
||||
return props.senders.filter((s) => s.profile_id === form.profile_id);
|
||||
});
|
||||
|
||||
function onTemplateChange() {
|
||||
const template = props.templates.find((t) => t.id === form.template_id);
|
||||
if (template?.content) {
|
||||
form.body = template.content;
|
||||
} else {
|
||||
form.body = "";
|
||||
}
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
const lines = (form.numbers || "")
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!lines.length) return;
|
||||
if (!form.profile_id && !form.template_id) {
|
||||
alert("Izberi SMS profil ali predlogo.");
|
||||
return;
|
||||
}
|
||||
if (!form.template_id && !form.body) {
|
||||
alert("Vnesi vsebino sporočila ali izberi predlogo.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type: "sms",
|
||||
name: form.name || `SMS paket ${new Date().toLocaleString()}`,
|
||||
description: form.description || "",
|
||||
items: lines.map((number) => ({
|
||||
number,
|
||||
payload: {
|
||||
profile_id: form.profile_id,
|
||||
sender_id: form.sender_id,
|
||||
template_id: form.template_id,
|
||||
delivery_report: !!form.delivery_report,
|
||||
body: form.body && form.body.trim() ? form.body.trim() : null,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
router.post(route("admin.packages.store"), payload, {
|
||||
onSuccess: () => {
|
||||
router.visit(route("admin.packages.index"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Contracts mode state & actions
|
||||
const contracts = ref({
|
||||
data: [],
|
||||
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
||||
});
|
||||
const segmentId = ref(null);
|
||||
const search = ref("");
|
||||
const clientId = ref(null);
|
||||
const startDateFrom = ref("");
|
||||
const startDateTo = ref("");
|
||||
const promiseDateFrom = ref("");
|
||||
const promiseDateTo = ref("");
|
||||
const onlyMobile = ref(false);
|
||||
const onlyValidated = ref(false);
|
||||
const loadingContracts = ref(false);
|
||||
const selectedContractIds = ref(new Set());
|
||||
const perPage = ref(25);
|
||||
|
||||
// DataTable columns definition
|
||||
const contractColumns = [
|
||||
{ accessorKey: "reference", header: "Pogodba" },
|
||||
{
|
||||
id: "person",
|
||||
accessorFn: (row) => row.person?.full_name || "—",
|
||||
header: "Primer",
|
||||
},
|
||||
{
|
||||
id: "client",
|
||||
accessorFn: (row) => row.client?.name || "—",
|
||||
header: "Stranka",
|
||||
},
|
||||
{ accessorKey: "start_date", header: "Datum začetka" },
|
||||
{ accessorKey: "promise_date", header: "Zadnja obljuba" },
|
||||
{
|
||||
id: "selected_phone",
|
||||
accessorFn: (row) => row.selected_phone?.number || "—",
|
||||
header: "Izbrana številka",
|
||||
},
|
||||
{ accessorKey: "no_phone_reason", header: "Opomba" },
|
||||
];
|
||||
|
||||
function onSelectionChange(selectedKeys) {
|
||||
// selectedKeys are indices from the table
|
||||
const newSelection = new Set();
|
||||
selectedKeys.forEach((key) => {
|
||||
const index = parseInt(key);
|
||||
if (contracts.value.data[index]) {
|
||||
newSelection.add(contracts.value.data[index].id);
|
||||
}
|
||||
});
|
||||
selectedContractIds.value = newSelection;
|
||||
}
|
||||
|
||||
async function loadContracts(url = null) {
|
||||
loadingContracts.value = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (segmentId.value) params.append("segment_id", segmentId.value);
|
||||
if (search.value) params.append("q", search.value);
|
||||
if (clientId.value) params.append("client_id", clientId.value);
|
||||
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value);
|
||||
if (startDateTo.value) params.append("start_date_to", startDateTo.value);
|
||||
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value);
|
||||
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value);
|
||||
if (onlyMobile.value) params.append("only_mobile", "1");
|
||||
if (onlyValidated.value) params.append("only_validated", "1");
|
||||
params.append("per_page", perPage.value);
|
||||
|
||||
const target = url || `${route("admin.packages.contracts")}?${params.toString()}`;
|
||||
const res = await fetch(target, {
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
// Wait for next tick before updating to avoid Vue reconciliation issues
|
||||
await nextTick();
|
||||
|
||||
contracts.value = {
|
||||
data: json.data || [],
|
||||
meta: json.meta || { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
||||
};
|
||||
} finally {
|
||||
loadingContracts.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectContract(id) {
|
||||
const s = selectedContractIds.value;
|
||||
if (s.has(id)) {
|
||||
s.delete(id);
|
||||
} else {
|
||||
s.add(id);
|
||||
}
|
||||
selectedContractIds.value = new Set(Array.from(s));
|
||||
}
|
||||
|
||||
// Get row selection state for DataTable
|
||||
const rowSelection = computed(() => {
|
||||
const selection = {};
|
||||
contracts.value.data.forEach((contract, index) => {
|
||||
if (selectedContractIds.value.has(contract.id)) {
|
||||
selection[index.toString()] = true;
|
||||
}
|
||||
});
|
||||
return selection;
|
||||
});
|
||||
|
||||
// Computed key to force DataTable re-render on page change
|
||||
const tableKey = computed(() => {
|
||||
return `contracts-${contracts.value.meta.current_page}-${contracts.value.data.length}`;
|
||||
});
|
||||
|
||||
function clearSelection() {
|
||||
selectedContractIds.value = new Set();
|
||||
}
|
||||
|
||||
function goToPage(page) {
|
||||
if (page < 1 || page > contracts.value.meta.last_page) return;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (segmentId.value) params.append("segment_id", segmentId.value);
|
||||
if (search.value) params.append("q", search.value);
|
||||
if (clientId.value) params.append("client_id", clientId.value);
|
||||
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value);
|
||||
if (startDateTo.value) params.append("start_date_to", startDateTo.value);
|
||||
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value);
|
||||
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value);
|
||||
if (onlyMobile.value) params.append("only_mobile", "1");
|
||||
if (onlyValidated.value) params.append("only_validated", "1");
|
||||
params.append("per_page", perPage.value);
|
||||
params.append("page", page);
|
||||
|
||||
const url = `${route("admin.packages.contracts")}?${params.toString()}`;
|
||||
loadContracts(url);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
segmentId.value = null;
|
||||
clientId.value = null;
|
||||
search.value = "";
|
||||
startDateFrom.value = "";
|
||||
startDateTo.value = "";
|
||||
promiseDateFrom.value = "";
|
||||
promiseDateTo.value = "";
|
||||
onlyMobile.value = false;
|
||||
onlyValidated.value = false;
|
||||
contracts.value = {
|
||||
data: [],
|
||||
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function submitCreateFromContracts() {
|
||||
const ids = Array.from(selectedContractIds.value);
|
||||
if (!ids.length) return;
|
||||
|
||||
const visibleById = new Map((contracts.value.data || []).map((c) => [c.id, c]));
|
||||
const selectedVisible = ids.map((id) => visibleById.get(id)).filter(Boolean);
|
||||
if (selectedVisible.length && selectedVisible.every((c) => !c?.selected_phone)) {
|
||||
alert("Za izbrane pogodbe ni mogoče najti prejemnikov (telefonov).");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type: "sms",
|
||||
name: form.name || `SMS paket (segment) ${new Date().toLocaleString()}`,
|
||||
description: form.description || "",
|
||||
payload: {
|
||||
profile_id: form.profile_id,
|
||||
sender_id: form.sender_id,
|
||||
template_id: form.template_id,
|
||||
delivery_report: !!form.delivery_report,
|
||||
body: form.body && form.body.trim() ? form.body.trim() : null,
|
||||
},
|
||||
contract_ids: ids,
|
||||
};
|
||||
|
||||
creatingFromContracts.value = true;
|
||||
router.post(route("admin.packages.store-from-contracts"), payload, {
|
||||
onSuccess: () => {
|
||||
router.visit(route("admin.packages.index"));
|
||||
},
|
||||
onError: (errors) => {
|
||||
const first = errors && Object.values(errors)[0];
|
||||
if (first) {
|
||||
alert(String(first));
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
creatingFromContracts.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const numbersCount = computed(() => {
|
||||
return (form.numbers || "")
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean).length;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout title="Ustvari SMS paket">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<Link :href="route('admin.packages.index')">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeftIcon class="h-4 w-4 mr-2" />
|
||||
Nazaj
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<PackageIcon class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Ustvari SMS paket</h1>
|
||||
<p class="text-sm text-muted-foreground">Pošlji SMS sporočila v paketu</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<Tabs v-model="createMode" class="w-full">
|
||||
<TabsList class="flex flex-row justify-baseline py-4">
|
||||
<TabsTrigger value="numbers" class="p-3">
|
||||
<span class="flex gap-2 items-center align-middle justify-center">
|
||||
<PhoneIcon class="h-5 w-5" />Vnos številk
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="contracts" class="p-3">
|
||||
<span class="flex gap-2 items-center align-middle justify-center">
|
||||
<UsersIcon class="h-5 w-5" />Iz pogodb (segment)
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- Package Details Card -->
|
||||
<Card class="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Podatki o paketu</CardTitle>
|
||||
<CardDescription>Osnovne informacije in SMS nastavitve</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Ime paketa</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
placeholder="Npr. SMS kampanja december 2024"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="description">Opis</Label>
|
||||
<Input
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
placeholder="Neobvezen opis paketa"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- SMS Configuration -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-4">SMS nastavitve</h3>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<Label>SMS profil</Label>
|
||||
<Select v-model="form.profile_id">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi profil" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">—</SelectItem>
|
||||
<SelectItem v-for="p in profiles" :key="p.id" :value="p.id">
|
||||
{{ p.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Pošiljatelj</Label>
|
||||
<Select v-model="form.sender_id">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi pošiljatelja" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">—</SelectItem>
|
||||
<SelectItem v-for="s in filteredSenders" :key="s.id" :value="s.id">
|
||||
{{ s.sname }}
|
||||
<span v-if="s.phone_number" class="text-muted-foreground">
|
||||
({{ s.phone_number }})
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Predloga</Label>
|
||||
<Select v-model="form.template_id" @update:model-value="onTemplateChange">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi predlogo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">—</SelectItem>
|
||||
<SelectItem v-for="t in templates" :key="t.id" :value="t.id">
|
||||
{{ t.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="body">Vsebina sporočila</Label>
|
||||
<Textarea
|
||||
id="body"
|
||||
v-model="form.body"
|
||||
rows="4"
|
||||
placeholder="Vsebina SMS sporočila..."
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="form.delivery_report"
|
||||
@update:checked="(val) => (form.delivery_report = val)"
|
||||
id="delivery-report"
|
||||
/>
|
||||
<Label for="delivery-report" class="cursor-pointer text-sm">
|
||||
Zahtevaj delivery report
|
||||
</Label>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ form.body?.length || 0 }} znakov
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Numbers Mode -->
|
||||
<TabsContent value="numbers">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Telefonske številke</CardTitle>
|
||||
<CardDescription
|
||||
>Vnesi telefonske številke prejemnikov (ena na vrstico)</CardDescription
|
||||
>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Textarea
|
||||
v-model="form.numbers"
|
||||
rows="10"
|
||||
placeholder="+38640123456 +38640123457 +38641234567"
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<strong>{{ numbersCount }}</strong>
|
||||
{{
|
||||
numbersCount === 1
|
||||
? "številka"
|
||||
: numbersCount < 5
|
||||
? "številke"
|
||||
: "številk"
|
||||
}}
|
||||
</p>
|
||||
<Badge v-if="numbersCount > 0" variant="secondary">
|
||||
<CheckCircle2Icon class="h-3 w-3 mr-1" />
|
||||
Pripravljeno
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
@click="router.visit(route('admin.packages.index'))"
|
||||
variant="outline"
|
||||
>
|
||||
Prekliči
|
||||
</Button>
|
||||
<Button
|
||||
@click="submitCreate"
|
||||
:disabled="numbersCount === 0 || (!form.profile_id && !form.template_id)"
|
||||
>
|
||||
<SaveIcon class="h-4 w-4 mr-2" />
|
||||
Ustvari paket
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Contracts Mode -->
|
||||
<TabsContent value="contracts">
|
||||
<Card class="mb-6">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Filtri za pogodbe</CardTitle>
|
||||
<CardDescription
|
||||
>Najdi prejemnike glede na pogodbe in segmente</CardDescription
|
||||
>
|
||||
</div>
|
||||
<Badge variant="outline" class="text-xs">
|
||||
<FilterIcon class="h-3 w-3 mr-1" />
|
||||
Napredno iskanje
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<!-- Basic filters -->
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<Label>Segment</Label>
|
||||
<Select v-model="segmentId" @update:model-value="loadContracts()">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Vsi segmenti" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Vsi segmenti</SelectItem>
|
||||
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Stranka</Label>
|
||||
<Select v-model="clientId" @update:model-value="loadContracts()">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Vse stranke" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Vse stranke</SelectItem>
|
||||
<SelectItem v-for="c in clients" :key="c.id" :value="c.id">
|
||||
{{ c.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Iskanje po referenci</Label>
|
||||
<Input
|
||||
v-model="search"
|
||||
@keyup.enter="loadContracts()"
|
||||
placeholder="Vnesi referenco..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Date filters -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<CalendarIcon class="h-4 w-4" />
|
||||
Datumski filtri
|
||||
</h4>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-muted-foreground">Datum začetka pogodbe</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Od</Label>
|
||||
<Input v-model="startDateFrom" type="date" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Do</Label>
|
||||
<Input v-model="startDateTo" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-muted-foreground">Datum obljube plačila</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Od</Label>
|
||||
<Input v-model="promiseDateFrom" type="date" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Do</Label>
|
||||
<Input v-model="promiseDateTo" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Phone filters -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold mb-3">Telefonski filtri</h4>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="onlyMobile"
|
||||
@update:checked="
|
||||
(val) => {
|
||||
onlyMobile = val;
|
||||
}
|
||||
"
|
||||
id="only-mobile"
|
||||
/>
|
||||
<Label for="only-mobile" class="cursor-pointer text-sm">
|
||||
Samo mobilne številke
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="onlyValidated"
|
||||
@update:checked="
|
||||
(val) => {
|
||||
onlyValidated = val;
|
||||
}
|
||||
"
|
||||
id="only-validated"
|
||||
/>
|
||||
<Label for="only-validated" class="cursor-pointer text-sm">
|
||||
Samo potrjene številke
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button @click="loadContracts()">
|
||||
<SearchIcon class="h-4 w-4 mr-2" />
|
||||
Išči pogodbe
|
||||
</Button>
|
||||
<Button @click="resetFilters" variant="outline">
|
||||
<XCircleIcon class="h-4 w-4 mr-2" />
|
||||
Počisti filtre
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Results -->
|
||||
<Card v-if="contracts.data.length > 0 || loadingContracts">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Rezultati iskanja</CardTitle>
|
||||
<CardDescription v-if="contracts.meta.total > 0">
|
||||
Najdeno {{ contracts.meta.total }}
|
||||
{{
|
||||
contracts.meta.total === 1
|
||||
? "pogodba"
|
||||
: contracts.meta.total < 5
|
||||
? "pogodbe"
|
||||
: "pogodb"
|
||||
}}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<!-- Create Button -->
|
||||
<div class="flex justify-end gap-2" v-if="selectedContractIds.size > 0">
|
||||
<Badge
|
||||
v-if="selectedContractIds.size > 0"
|
||||
variant="secondary"
|
||||
class="text-sm"
|
||||
>
|
||||
<CheckCircle2Icon class="h-3 w-3 mr-1" />
|
||||
Izbrano: {{ selectedContractIds.size }}
|
||||
</Badge>
|
||||
<Button
|
||||
@click="router.visit(route('admin.packages.index'))"
|
||||
variant="outline"
|
||||
>
|
||||
Prekliči
|
||||
</Button>
|
||||
<Button
|
||||
@click="submitCreateFromContracts"
|
||||
:disabled="selectedContractIds.size === 0 || creatingFromContracts"
|
||||
>
|
||||
<SaveIcon class="h-4 w-4 mr-2" />
|
||||
Ustvari paket ({{ selectedContractIds.size }}
|
||||
{{
|
||||
selectedContractIds.size === 1
|
||||
? "pogodba"
|
||||
: selectedContractIds.size < 5
|
||||
? "pogodbe"
|
||||
: "pogodb"
|
||||
}})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<DataTableNew2
|
||||
v-if="!loadingContracts"
|
||||
:key="tableKey"
|
||||
:columns="contractColumns"
|
||||
:data="contracts.data"
|
||||
:enableRowSelection="true"
|
||||
:rowSelection="rowSelection"
|
||||
:showPagination="true"
|
||||
:page-size="50"
|
||||
:page-size-options="[10, 15, 25, 50, 100]"
|
||||
:showToolbar="false"
|
||||
@selection:change="onSelectionChange"
|
||||
>
|
||||
<template #cell-reference="{ row }">
|
||||
<div v-if="row.original" class="space-y-1">
|
||||
<p class="font-medium">{{ row.original.reference || "—" }}</p>
|
||||
<p class="text-xs text-muted-foreground font-mono">
|
||||
#{{ row.original.id }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-person="{ row }">
|
||||
<span v-if="row.original" class="text-xs">{{
|
||||
row.original.person?.full_name || "—"
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-client="{ row }">
|
||||
<span v-if="row.original" class="text-xs">{{
|
||||
row.original.client?.name || "—"
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-start_date="{ row }">
|
||||
{{ fmtDateDMY(row.start_date) || "—" }}
|
||||
</template>
|
||||
|
||||
<template #cell-promise_date="{ row }">
|
||||
{{ fmtDateDMY(row.promise_date) || "—" }}
|
||||
</template>
|
||||
|
||||
<template #cell-selected_phone="{ row }">
|
||||
<div v-if="row.selected_phone" class="space-y-1">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span>{{ row.selected_phone.number }}</span>
|
||||
<span
|
||||
><Badge
|
||||
v-if="row.selected_phone.validated"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
<BadgeCheckIcon />
|
||||
Potrjena
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else
|
||||
variant="destructive"
|
||||
class="h-5 min-w-5 rounded-full px-1 font-mono tabular-nums text-accent"
|
||||
>
|
||||
Nepotrjena
|
||||
</Badge></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-xs text-destructive">Ni telefonske št.</span>
|
||||
</template>
|
||||
|
||||
<template #cell-no_phone_reason="{ row }">
|
||||
<span v-if="row.original" class="text-xs text-muted-foreground">{{
|
||||
row.original.no_phone_reason || "—"
|
||||
}}</span>
|
||||
</template>
|
||||
</DataTableNew2>
|
||||
|
||||
<div v-else class="text-center text-muted-foreground py-24">Nalaganje...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
|
@ -1,53 +1,31 @@
|
|||
<script setup>
|
||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||
import { Link, router, useForm } from "@inertiajs/vue3";
|
||||
import { ref, computed } from "vue";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import { ref } from "vue";
|
||||
import { Card, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import Pagination from "@/Components/Pagination.vue";
|
||||
import {
|
||||
PackageIcon,
|
||||
PlusIcon,
|
||||
XIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
EyeIcon,
|
||||
} from "lucide-vue-next";
|
||||
import { PackageIcon, PlusIcon, Trash2Icon, EyeIcon } from "lucide-vue-next";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
|
||||
const props = defineProps({
|
||||
packages: { type: Object, required: true },
|
||||
profiles: { type: Array, default: () => [] },
|
||||
senders: { type: Array, default: () => [] },
|
||||
templates: { type: Array, default: () => [] },
|
||||
segments: { type: Array, default: () => [] },
|
||||
clients: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const deletingId = ref(null);
|
||||
const creatingFromContracts = ref(false);
|
||||
const packageToDelete = ref(null);
|
||||
const showDeleteDialog = ref(false);
|
||||
|
||||
const columns = [
|
||||
{ accessorKey: "id", header: "ID" },
|
||||
|
|
@ -73,242 +51,23 @@ function goShow(id) {
|
|||
router.visit(route("admin.packages.show", id));
|
||||
}
|
||||
|
||||
const showCreate = ref(false);
|
||||
const createMode = ref("numbers"); // 'numbers' | 'contracts'
|
||||
const form = useForm({
|
||||
type: "sms",
|
||||
name: "",
|
||||
description: "",
|
||||
profile_id: null,
|
||||
sender_id: null,
|
||||
template_id: null,
|
||||
delivery_report: false,
|
||||
body: "",
|
||||
numbers: "", // one per line
|
||||
});
|
||||
|
||||
const filteredSenders = computed(() => {
|
||||
if (!form.profile_id) return props.senders;
|
||||
return props.senders.filter((s) => s.profile_id === form.profile_id);
|
||||
});
|
||||
|
||||
function onTemplateChange() {
|
||||
const template = props.templates.find((t) => t.id === form.template_id);
|
||||
if (template?.content) {
|
||||
form.body = template.content;
|
||||
} else {
|
||||
form.body = "";
|
||||
}
|
||||
}
|
||||
|
||||
function submitCreate() {
|
||||
const lines = (form.numbers || "")
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!lines.length) return;
|
||||
if (!form.profile_id && !form.template_id) {
|
||||
// require profile if no template/default profile resolution available
|
||||
alert("Izberi SMS profil ali predlogo.");
|
||||
return;
|
||||
}
|
||||
if (!form.template_id && !form.body) {
|
||||
alert("Vnesi vsebino sporočila ali izberi predlogo.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
type: "sms",
|
||||
name: form.name || `SMS paket ${new Date().toLocaleString()}`,
|
||||
description: form.description || "",
|
||||
items: lines.map((number) => ({
|
||||
number,
|
||||
payload: {
|
||||
profile_id: form.profile_id,
|
||||
sender_id: form.sender_id,
|
||||
template_id: form.template_id,
|
||||
delivery_report: !!form.delivery_report,
|
||||
body: form.body && form.body.trim() ? form.body.trim() : null,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
router.post(route("admin.packages.store"), payload, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
showCreate.value = false;
|
||||
router.reload({ only: ["packages"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Contracts mode state & actions
|
||||
const contracts = ref({
|
||||
data: [],
|
||||
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
||||
});
|
||||
const segmentId = ref(null);
|
||||
const search = ref("");
|
||||
const clientId = ref(null);
|
||||
const startDateFrom = ref("");
|
||||
const startDateTo = ref("");
|
||||
const promiseDateFrom = ref("");
|
||||
const promiseDateTo = ref("");
|
||||
const onlyMobile = ref(false);
|
||||
const onlyValidated = ref(false);
|
||||
const loadingContracts = ref(false);
|
||||
const selectedContractIds = ref(new Set());
|
||||
const perPage = ref(25);
|
||||
|
||||
async function loadContracts(url = null) {
|
||||
loadingContracts.value = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (segmentId.value) params.append("segment_id", segmentId.value);
|
||||
if (search.value) params.append("q", search.value);
|
||||
if (clientId.value) params.append("client_id", clientId.value);
|
||||
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value);
|
||||
if (startDateTo.value) params.append("start_date_to", startDateTo.value);
|
||||
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value);
|
||||
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value);
|
||||
if (onlyMobile.value) params.append("only_mobile", "1");
|
||||
if (onlyValidated.value) params.append("only_validated", "1");
|
||||
params.append("per_page", perPage.value);
|
||||
|
||||
const target = url || `${route("admin.packages.contracts")}?${params.toString()}`;
|
||||
const res = await fetch(target, {
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||
});
|
||||
const json = await res.json();
|
||||
contracts.value = {
|
||||
data: json.data || [],
|
||||
meta: json.meta || { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
||||
};
|
||||
} finally {
|
||||
loadingContracts.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectContract(id) {
|
||||
const s = selectedContractIds.value;
|
||||
if (s.has(id)) {
|
||||
s.delete(id);
|
||||
} else {
|
||||
s.add(id);
|
||||
}
|
||||
// force reactivity
|
||||
selectedContractIds.value = new Set(Array.from(s));
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedContractIds.value = new Set();
|
||||
}
|
||||
|
||||
function deletePackage(pkg) {
|
||||
function openDeleteDialog(pkg) {
|
||||
if (!pkg || pkg.status !== "draft") return;
|
||||
if (!confirm(`Izbrišem paket #${pkg.id}?`)) return;
|
||||
deletingId.value = pkg.id;
|
||||
router.delete(route("admin.packages.destroy", pkg.id), {
|
||||
packageToDelete.value = pkg;
|
||||
showDeleteDialog.value = true;
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!packageToDelete.value) return;
|
||||
deletingId.value = packageToDelete.value.id;
|
||||
router.delete(route("admin.packages.destroy", packageToDelete.value.id), {
|
||||
onSuccess: () => {
|
||||
router.reload({ only: ["packages"] });
|
||||
},
|
||||
onFinish: () => {
|
||||
deletingId.value = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const currentPageIds = contracts.value.data.map((c) => c.id);
|
||||
const allSelected = currentPageIds.every((id) => selectedContractIds.value.has(id));
|
||||
|
||||
if (allSelected) {
|
||||
// Deselect all on current page
|
||||
currentPageIds.forEach((id) => selectedContractIds.value.delete(id));
|
||||
} else {
|
||||
// Select all on current page
|
||||
currentPageIds.forEach((id) => selectedContractIds.value.add(id));
|
||||
}
|
||||
|
||||
// Force reactivity
|
||||
selectedContractIds.value = new Set(Array.from(selectedContractIds.value));
|
||||
}
|
||||
|
||||
const allCurrentPageSelected = computed(() => {
|
||||
if (!contracts.value.data.length) return false;
|
||||
return contracts.value.data.every((c) => selectedContractIds.value.has(c.id));
|
||||
});
|
||||
|
||||
const someCurrentPageSelected = computed(() => {
|
||||
if (!contracts.value.data.length) return false;
|
||||
return (
|
||||
contracts.value.data.some((c) => selectedContractIds.value.has(c.id)) &&
|
||||
!allCurrentPageSelected.value
|
||||
);
|
||||
});
|
||||
|
||||
function goContractsPage(delta) {
|
||||
const { current_page } = contracts.value.meta;
|
||||
const nextPage = current_page + delta;
|
||||
if (nextPage < 1 || nextPage > contracts.value.meta.last_page) return;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (segmentId.value) params.append("segment_id", segmentId.value);
|
||||
if (search.value) params.append("q", search.value);
|
||||
if (clientId.value) params.append("client_id", clientId.value);
|
||||
if (startDateFrom.value) params.append("start_date_from", startDateFrom.value);
|
||||
if (startDateTo.value) params.append("start_date_to", startDateTo.value);
|
||||
if (promiseDateFrom.value) params.append("promise_date_from", promiseDateFrom.value);
|
||||
if (promiseDateTo.value) params.append("promise_date_to", promiseDateTo.value);
|
||||
if (onlyMobile.value) params.append("only_mobile", "1");
|
||||
if (onlyValidated.value) params.append("only_validated", "1");
|
||||
params.append("per_page", perPage.value);
|
||||
params.append("page", nextPage);
|
||||
|
||||
const base = `${route("admin.packages.contracts")}?${params.toString()}`;
|
||||
loadContracts(base);
|
||||
}
|
||||
|
||||
function submitCreateFromContracts() {
|
||||
const ids = Array.from(selectedContractIds.value);
|
||||
if (!ids.length) return;
|
||||
// Optional quick client-side sanity: if all selected are from current page and none have phones, warn early.
|
||||
const visibleById = new Map((contracts.value.data || []).map((c) => [c.id, c]));
|
||||
const selectedVisible = ids.map((id) => visibleById.get(id)).filter(Boolean);
|
||||
if (selectedVisible.length && selectedVisible.every((c) => !c?.selected_phone)) {
|
||||
alert("Za izbrane pogodbe ni mogoče najti prejemnikov (telefonov).");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
type: "sms",
|
||||
name: form.name || `SMS paket (segment) ${new Date().toLocaleString()}`,
|
||||
description: form.description || "",
|
||||
payload: {
|
||||
profile_id: form.profile_id,
|
||||
sender_id: form.sender_id,
|
||||
template_id: form.template_id,
|
||||
delivery_report: !!form.delivery_report,
|
||||
body: form.body && form.body.trim() ? form.body.trim() : null,
|
||||
},
|
||||
contract_ids: ids,
|
||||
};
|
||||
|
||||
creatingFromContracts.value = true;
|
||||
router.post(route("admin.packages.store-from-contracts"), payload, {
|
||||
onSuccess: () => {
|
||||
clearSelection();
|
||||
showCreate.value = false;
|
||||
router.reload({ only: ["packages"] });
|
||||
},
|
||||
onError: (errors) => {
|
||||
// Show the first validation error if present
|
||||
const first = errors && Object.values(errors)[0];
|
||||
if (first) {
|
||||
alert(String(first));
|
||||
}
|
||||
},
|
||||
onFinish: () => {
|
||||
creatingFromContracts.value = false;
|
||||
showDeleteDialog.value = false;
|
||||
packageToDelete.value = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -323,444 +82,16 @@ function submitCreateFromContracts() {
|
|||
<PackageIcon class="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>SMS paketi</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
@click="showCreate = !showCreate"
|
||||
:variant="showCreate ? 'outline' : 'default'"
|
||||
>
|
||||
<component :is="showCreate ? XIcon : PlusIcon" class="h-4 w-4 mr-2" />
|
||||
{{ showCreate ? "Zapri" : "Nov paket" }}
|
||||
</Button>
|
||||
<Link :href="route('admin.packages.create')">
|
||||
<Button>
|
||||
<PlusIcon class="h-4 w-4 mr-2" />
|
||||
Nov paket
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card v-if="showCreate" class="mb-6">
|
||||
<CardContent class="pt-6">
|
||||
<div class="mb-4 flex items-center gap-4">
|
||||
<Label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="numbers"
|
||||
v-model="createMode"
|
||||
class="rounded-full"
|
||||
/>
|
||||
Vnos številk
|
||||
</Label>
|
||||
<Label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="contracts"
|
||||
v-model="createMode"
|
||||
class="rounded-full"
|
||||
/>
|
||||
Iz pogodb (segment)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Profil</Label>
|
||||
<Select v-model="form.profile_id">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="—" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">—</SelectItem>
|
||||
<SelectItem v-for="p in profiles" :key="p.id" :value="p.id">{{
|
||||
p.name
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Pošiljatelj</Label>
|
||||
<Select v-model="form.sender_id">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="—" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">—</SelectItem>
|
||||
<SelectItem v-for="s in filteredSenders" :key="s.id" :value="s.id">
|
||||
{{ s.sname }} <span v-if="s.phone_number">({{ s.phone_number }})</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Predloga</Label>
|
||||
<Select v-model="form.template_id" @update:model-value="onTemplateChange">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="—" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">—</SelectItem>
|
||||
<SelectItem v-for="t in templates" :key="t.id" :value="t.id">{{
|
||||
t.name
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="sm:col-span-3 space-y-2">
|
||||
<Label>Vsebina (če ni predloge)</Label>
|
||||
<Textarea v-model="form.body" rows="3" placeholder="Sporočilo..." />
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="form.delivery_report"
|
||||
@update:checked="(val) => (form.delivery_report = val)"
|
||||
id="delivery-report"
|
||||
/>
|
||||
<Label for="delivery-report" class="cursor-pointer"
|
||||
>Zahtevaj delivery report</Label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Numbers mode -->
|
||||
<template v-if="createMode === 'numbers'">
|
||||
<div class="sm:col-span-3 space-y-2">
|
||||
<Label>Telefonske številke (ena na vrstico)</Label>
|
||||
<Textarea
|
||||
v-model="form.numbers"
|
||||
rows="4"
|
||||
placeholder="+38640123456 +38640123457"
|
||||
/>
|
||||
</div>
|
||||
<div class="sm:col-span-3 flex items-center justify-end gap-2">
|
||||
<Button @click="submitCreate"> Ustvari paket </Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Contracts mode -->
|
||||
<template v-else>
|
||||
<div class="sm:col-span-3 space-y-4">
|
||||
<Separator />
|
||||
<!-- Basic filters -->
|
||||
<div class="grid sm:grid-cols-3 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>Segment</Label>
|
||||
<Select v-model="segmentId" @update:model-value="loadContracts()">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Vsi segmenti" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Vsi segmenti</SelectItem>
|
||||
<SelectItem v-for="s in segments" :key="s.id" :value="s.id">{{
|
||||
s.name
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Stranka</Label>
|
||||
<Select v-model="clientId" @update:model-value="loadContracts()">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Vse stranke" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">Vse stranke</SelectItem>
|
||||
<SelectItem v-for="c in clients" :key="c.id" :value="c.id">{{
|
||||
c.name
|
||||
}}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Iskanje po referenci</Label>
|
||||
<Input
|
||||
v-model="search"
|
||||
@keyup.enter="loadContracts()"
|
||||
type="text"
|
||||
placeholder="Vnesi referenco..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date range filters -->
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold mb-3">Datumski filtri</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-muted-foreground mb-2">
|
||||
Datum začetka pogodbe
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-2">
|
||||
<Label>Od</Label>
|
||||
<Input
|
||||
v-model="startDateFrom"
|
||||
@change="loadContracts()"
|
||||
type="date"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Do</Label>
|
||||
<Input
|
||||
v-model="startDateTo"
|
||||
@change="loadContracts()"
|
||||
type="date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-muted-foreground mb-2">
|
||||
Datum obljube plačila
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-2">
|
||||
<Label>Od</Label>
|
||||
<Input
|
||||
v-model="promiseDateFrom"
|
||||
@change="loadContracts()"
|
||||
type="date"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>Do</Label>
|
||||
<Input
|
||||
v-model="promiseDateTo"
|
||||
@change="loadContracts()"
|
||||
type="date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phone filters -->
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold mb-3">Telefonski filtri</h4>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="onlyMobile"
|
||||
@update:checked="
|
||||
(val) => {
|
||||
onlyMobile = val;
|
||||
loadContracts();
|
||||
}
|
||||
"
|
||||
id="only-mobile"
|
||||
/>
|
||||
<Label for="only-mobile" class="cursor-pointer"
|
||||
>Samo mobilne številke</Label
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="onlyValidated"
|
||||
@update:checked="
|
||||
(val) => {
|
||||
onlyValidated = val;
|
||||
loadContracts();
|
||||
}
|
||||
"
|
||||
id="only-validated"
|
||||
/>
|
||||
<Label for="only-validated" class="cursor-pointer"
|
||||
>Samo potrjene številke</Label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button @click="loadContracts()">
|
||||
<SearchIcon class="h-4 w-4 mr-2" />
|
||||
Išči pogodbe
|
||||
</Button>
|
||||
<Button
|
||||
@click="
|
||||
segmentId = null;
|
||||
clientId = null;
|
||||
search = '';
|
||||
startDateFrom = '';
|
||||
startDateTo = '';
|
||||
promiseDateFrom = '';
|
||||
promiseDateTo = '';
|
||||
onlyMobile = false;
|
||||
onlyValidated = false;
|
||||
contracts.value = {
|
||||
data: [],
|
||||
meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 },
|
||||
};
|
||||
"
|
||||
variant="outline"
|
||||
>
|
||||
Počisti filtre
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results table -->
|
||||
<div class="sm:col-span-3">
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allCurrentPageSelected"
|
||||
:indeterminate="someCurrentPageSelected"
|
||||
@change="toggleSelectAll"
|
||||
:disabled="!contracts.data.length"
|
||||
class="rounded"
|
||||
title="Izberi vse na tej strani"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Pogodba</TableHead>
|
||||
<TableHead>Primer</TableHead>
|
||||
<TableHead>Stranka</TableHead>
|
||||
<TableHead>Datum začetka</TableHead>
|
||||
<TableHead>Zadnja obljuba</TableHead>
|
||||
<TableHead>Izbrana številka</TableHead>
|
||||
<TableHead>Opomba</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody v-if="!loadingContracts">
|
||||
<TableRow v-for="c in contracts.data" :key="c.id">
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedContractIds.has(c.id)"
|
||||
@change="toggleSelectContract(c.id)"
|
||||
class="rounded"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="font-mono text-xs text-muted-foreground">
|
||||
{{ c.uuid }}
|
||||
</div>
|
||||
<a
|
||||
v-if="c.case?.uuid"
|
||||
:href="route('clientCase.show', c.case.uuid)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{{ c.reference }}
|
||||
</a>
|
||||
<div v-else class="text-xs font-medium">{{ c.reference }}</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-xs">
|
||||
{{ c.person?.full_name || "—" }}
|
||||
</TableCell>
|
||||
<TableCell class="text-xs">{{ c.client?.name || "—" }}</TableCell>
|
||||
<TableCell class="text-xs">{{
|
||||
c.start_date
|
||||
? new Date(c.start_date).toLocaleDateString("sl-SI")
|
||||
: "—"
|
||||
}}</TableCell>
|
||||
<TableCell class="text-xs">{{
|
||||
c.promise_date
|
||||
? new Date(c.promise_date).toLocaleDateString("sl-SI")
|
||||
: "—"
|
||||
}}</TableCell>
|
||||
<TableCell>
|
||||
<div v-if="c.selected_phone" class="text-xs">
|
||||
{{ c.selected_phone.number }}
|
||||
<Badge
|
||||
v-if="c.selected_phone.is_mobile"
|
||||
variant="secondary"
|
||||
class="ml-1"
|
||||
>mobitel</Badge
|
||||
>
|
||||
<Badge
|
||||
v-if="c.selected_phone.is_validated"
|
||||
variant="default"
|
||||
class="ml-1"
|
||||
>potrjen</Badge
|
||||
>
|
||||
</div>
|
||||
<div v-else class="text-xs text-muted-foreground">—</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-xs text-muted-foreground">
|
||||
{{ c.no_phone_reason || "—" }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!contracts.data?.length">
|
||||
<TableCell colspan="8" class="text-center text-muted-foreground h-24">
|
||||
Ni rezultatov. Kliknite "Išči pogodbe" za prikaz.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
<TableBody v-else>
|
||||
<TableRow
|
||||
><TableCell colspan="8" class="text-center text-muted-foreground h-24"
|
||||
>Nalaganje...</TableCell
|
||||
></TableRow
|
||||
>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<div class="text-sm text-muted-foreground flex items-center gap-4">
|
||||
<span v-if="contracts.data.length">
|
||||
Prikazano stran {{ contracts.meta.current_page }} od
|
||||
{{ contracts.meta.last_page }} (skupaj {{ contracts.meta.total }})
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-xs">Na stran:</Label>
|
||||
<Select v-model="perPage" @update:model-value="loadContracts()">
|
||||
<SelectTrigger class="w-20 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="10">10</SelectItem>
|
||||
<SelectItem :value="25">25</SelectItem>
|
||||
<SelectItem :value="50">50</SelectItem>
|
||||
<SelectItem :value="100">100</SelectItem>
|
||||
<SelectItem :value="200">200</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@click="goContractsPage(-1)"
|
||||
:disabled="contracts.meta.current_page <= 1"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>Nazaj</Button
|
||||
>
|
||||
<Button
|
||||
@click="goContractsPage(1)"
|
||||
:disabled="contracts.meta.current_page >= contracts.meta.last_page"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>Naprej</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator class="sm:col-span-3" />
|
||||
<div class="sm:col-span-3 flex items-center justify-between gap-2">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">Izbrano: {{ selectedContractIds.size }}</span>
|
||||
<span v-if="selectedContractIds.size > 0" class="ml-2 text-muted-foreground"
|
||||
>({{
|
||||
selectedContractIds.size === 1
|
||||
? "1 pogodba"
|
||||
: `${selectedContractIds.size} pogodb`
|
||||
}})</span
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
@click="submitCreateFromContracts"
|
||||
:disabled="selectedContractIds.size === 0 || creatingFromContracts"
|
||||
>Ustvari paket</Button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
|
|
@ -771,7 +102,7 @@ function submitCreateFromContracts() {
|
|||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<PackageIcon size="18" />
|
||||
<CardTitle class="uppercase">Uvozi</CardTitle>
|
||||
<CardTitle class="uppercase">Paketi</CardTitle>
|
||||
</div>
|
||||
</template>
|
||||
<DataTableNew2
|
||||
|
|
@ -807,7 +138,7 @@ function submitCreateFromContracts() {
|
|||
</Button>
|
||||
<Button
|
||||
v-if="row.status === 'draft'"
|
||||
@click="deletePackage(row)"
|
||||
@click="openDeleteDialog(row)"
|
||||
:disabled="deletingId === row.id"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -818,5 +149,28 @@ function submitCreateFromContracts() {
|
|||
</template>
|
||||
</DataTableNew2>
|
||||
</AppCard>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<AlertDialog v-model:open="showDeleteDialog">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Izbriši paket?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Ali ste prepričani, da želite izbrisati paket
|
||||
<strong v-if="packageToDelete">#{{ packageToDelete.id }} - {{ packageToDelete.name || 'Brez imena' }}</strong>?
|
||||
Tega dejanja ni mogoče razveljaviti.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Prekliči</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
@click="confirmDelete"
|
||||
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Izbriši
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Switch } from "@/Components/ui/switch";
|
||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||
import { ref, watch, computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
|
|
@ -53,7 +54,7 @@ const form = useInertiaForm({
|
|||
props.actions[0].decisions.length > 0
|
||||
? props.actions[0].decisions[0].id
|
||||
: null,
|
||||
contract_uuid: props.contractUuid,
|
||||
contract_uuids: props.contractUuid ? [props.contractUuid] : [],
|
||||
send_auto_mail: true,
|
||||
attach_documents: false,
|
||||
attachment_document_ids: [],
|
||||
|
|
@ -95,7 +96,7 @@ watch(
|
|||
watch(
|
||||
() => props.contractUuid,
|
||||
(cu) => {
|
||||
form.contract_uuid = cu || null;
|
||||
form.contract_uuids = cu ? [cu] : [];
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -103,7 +104,7 @@ watch(
|
|||
() => props.show,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
form.contract_uuid = props.contractUuid || null;
|
||||
form.contract_uuids = props.contractUuid ? [props.contractUuid] : [];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
@ -119,20 +120,28 @@ const store = async () => {
|
|||
return `${y}-${m}-${day}`;
|
||||
};
|
||||
|
||||
const contractUuids = Array.isArray(form.contract_uuids) && form.contract_uuids.length > 0
|
||||
? form.contract_uuids
|
||||
: null;
|
||||
|
||||
const isMultipleContracts = contractUuids && contractUuids.length > 1;
|
||||
|
||||
form
|
||||
.transform((data) => ({
|
||||
...data,
|
||||
phone_view: props.phoneMode,
|
||||
due_date: formatDateForSubmit(data.due_date),
|
||||
contract_uuids: contractUuids,
|
||||
create_for_all_contracts: isMultipleContracts,
|
||||
attachment_document_ids:
|
||||
templateAllowsAttachments.value && data.attach_documents
|
||||
templateAllowsAttachments.value && data.attach_documents && !isMultipleContracts
|
||||
? data.attachment_document_ids
|
||||
: [],
|
||||
}))
|
||||
.post(route("clientCase.activity.store", props.client_case), {
|
||||
onSuccess: () => {
|
||||
close();
|
||||
form.reset("due_date", "amount", "note");
|
||||
form.reset("due_date", "amount", "note", "contract_uuids");
|
||||
emit("saved");
|
||||
},
|
||||
});
|
||||
|
|
@ -165,13 +174,39 @@ const autoMailRequiresContract = computed(() => {
|
|||
return types.includes("contract");
|
||||
});
|
||||
|
||||
const autoMailDisabled = computed(() => {
|
||||
return showSendAutoMail() && autoMailRequiresContract.value && !form.contract_uuid;
|
||||
const contractItems = computed(() => {
|
||||
return pageContracts.value.map(c => ({
|
||||
value: c.uuid,
|
||||
label: `${c.reference}${c.name ? ` - ${c.name}` : ''}`
|
||||
}));
|
||||
});
|
||||
|
||||
const autoMailDisabled = computed(() => {
|
||||
if (!showSendAutoMail()) return false;
|
||||
|
||||
// Disable if multiple contracts selected
|
||||
if (form.contract_uuids && form.contract_uuids.length > 1) return true;
|
||||
|
||||
// Disable if template requires contract but none selected
|
||||
if (autoMailRequiresContract.value && (!form.contract_uuids || form.contract_uuids.length === 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const autoMailDisabledHint = computed(() => {
|
||||
return autoMailDisabled.value
|
||||
? "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo."
|
||||
: "";
|
||||
if (!showSendAutoMail()) return "";
|
||||
|
||||
if (form.contract_uuids && form.contract_uuids.length > 1) {
|
||||
return "Avtomatska e-pošta ni na voljo pri več pogodbah.";
|
||||
}
|
||||
|
||||
if (autoMailRequiresContract.value && (!form.contract_uuids || form.contract_uuids.length === 0)) {
|
||||
return "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo.";
|
||||
}
|
||||
|
||||
return "";
|
||||
});
|
||||
watch(
|
||||
() => autoMailDisabled.value,
|
||||
|
|
@ -231,9 +266,12 @@ const docsSource = computed(() => {
|
|||
});
|
||||
|
||||
const availableContractDocs = computed(() => {
|
||||
if (!form.contract_uuid) return [];
|
||||
if (!form.contract_uuids || form.contract_uuids.length === 0) return [];
|
||||
// Only show docs if exactly one contract is selected
|
||||
if (form.contract_uuids.length > 1) return [];
|
||||
const selectedUuid = form.contract_uuids[0];
|
||||
const docs = docsSource.value;
|
||||
const all = docs.filter((d) => d.contract_uuid === form.contract_uuid);
|
||||
const all = docs.filter((d) => d.contract_uuid === selectedUuid);
|
||||
if (!props.phoneMode) return all;
|
||||
return all.filter((d) => {
|
||||
const mime = (d.mime_type || "").toLowerCase();
|
||||
|
|
@ -264,14 +302,14 @@ watch(
|
|||
[
|
||||
() => props.phoneMode,
|
||||
() => templateAllowsAttachments.value,
|
||||
() => form.contract_uuid,
|
||||
() => form.contract_uuids,
|
||||
() => form.decision_id,
|
||||
() => availableContractDocs.value.length,
|
||||
],
|
||||
() => {
|
||||
if (!props.phoneMode) return;
|
||||
if (!templateAllowsAttachments.value) return;
|
||||
if (!form.contract_uuid) return;
|
||||
if (!form.contract_uuids || form.contract_uuids.length !== 1) return;
|
||||
const docs = availableContractDocs.value;
|
||||
if (docs.length === 0) return;
|
||||
form.attach_documents = true;
|
||||
|
|
@ -324,6 +362,22 @@ watch(
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Pogodbe</Label>
|
||||
<AppMultiSelect
|
||||
v-model="form.contract_uuids"
|
||||
:items="contractItems"
|
||||
placeholder="Izberi pogodbe (neobvezno)"
|
||||
search-placeholder="Išči pogodbo..."
|
||||
empty-text="Ni pogodb."
|
||||
:clearable="true"
|
||||
:show-selected-chips="true"
|
||||
/>
|
||||
<p v-if="form.contract_uuids && form.contract_uuids.length > 1" class="text-xs text-muted-foreground">
|
||||
Bo ustvarjenih {{ form.contract_uuids.length }} aktivnosti (ena za vsako pogodbo).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="activityNote">Opomba</Label>
|
||||
<Textarea
|
||||
|
|
@ -369,7 +423,7 @@ watch(
|
|||
{{ autoMailDisabledHint }}
|
||||
</p>
|
||||
|
||||
<div v-if="templateAllowsAttachments && form.contract_uuid" class="mt-3">
|
||||
<div v-if="templateAllowsAttachments && form.contract_uuids && form.contract_uuids.length === 1" class="mt-3">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<Switch v-model="form.attach_documents" />
|
||||
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
|
||||
|
|
@ -383,7 +437,7 @@ watch(
|
|||
</div>
|
||||
<div class="space-y-1">
|
||||
<template v-for="c in pageContracts" :key="c.uuid || c.id">
|
||||
<div v-if="c.uuid === form.contract_uuid">
|
||||
<div v-if="c.uuid === form.contract_uuids[0]">
|
||||
<div class="font-medium text-sm text-gray-700 mb-1">
|
||||
Pogodba {{ c.reference }}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,40 @@
|
|||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import SectionTitle from "@/Components/SectionTitle.vue";
|
||||
import DataTableServer from "@/Components/DataTable/DataTableServer.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/Components/ui/dropdown-menu";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import { Bell, BellOff, Check, ChevronDown, X, Inbox } from "lucide-vue-next";
|
||||
import TableActions from "@/Components/DataTable/TableActions.vue";
|
||||
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
|
||||
import { number } from "zod";
|
||||
import { toNumber } from "lodash";
|
||||
|
||||
const props = defineProps({
|
||||
activities: { type: Object, required: true },
|
||||
today: { type: String, required: true },
|
||||
// Optional: full list of clients with unread items to populate filter dropdown
|
||||
clients: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
|
|
@ -21,6 +46,7 @@ function fmtDate(d) {
|
|||
return String(d);
|
||||
}
|
||||
}
|
||||
|
||||
function fmtEUR(value) {
|
||||
if (value === null || value === undefined) return "—";
|
||||
const num = typeof value === "string" ? Number(value) : value;
|
||||
|
|
@ -34,13 +60,12 @@ function fmtEUR(value) {
|
|||
return formatted.replace("\u00A0", " ");
|
||||
}
|
||||
|
||||
// --- Client filter (like Segments/Show.vue) ---
|
||||
// Client filter
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const initialClient = urlParams.get("client") || urlParams.get("client_id") || "";
|
||||
const selectedClient = ref(initialClient);
|
||||
|
||||
const clientOptions = computed(() => {
|
||||
// Prefer server-provided clients list; fallback to deriving from rows
|
||||
const list =
|
||||
Array.isArray(props.clients) && props.clients.length
|
||||
? props.clients
|
||||
|
|
@ -72,43 +97,20 @@ watch(selectedClient, (val) => {
|
|||
});
|
||||
});
|
||||
|
||||
// Row selection - connected to DataTableNew2's built-in selection
|
||||
const selectedRows = ref([]);
|
||||
const dataTableRef = ref(null);
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (selectedRows.value.length === (props.activities.data?.length || 0)) {
|
||||
selectedRows.value = [];
|
||||
} else {
|
||||
selectedRows.value = (props.activities.data || []).map((row) => row.id);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRowSelection(id) {
|
||||
const idx = selectedRows.value.indexOf(id);
|
||||
if (idx > -1) {
|
||||
selectedRows.value.splice(idx, 1);
|
||||
} else {
|
||||
selectedRows.value.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
function isRowSelected(id) {
|
||||
return selectedRows.value.includes(id);
|
||||
}
|
||||
|
||||
function isAllSelected() {
|
||||
return (
|
||||
(props.activities.data?.length || 0) > 0 &&
|
||||
selectedRows.value.length === (props.activities.data?.length || 0)
|
||||
);
|
||||
}
|
||||
|
||||
function isIndeterminate() {
|
||||
return (
|
||||
selectedRows.value.length > 0 &&
|
||||
selectedRows.value.length < (props.activities.data?.length || 0)
|
||||
);
|
||||
function handleSelectionChange(selectedKeys) {
|
||||
selectedRows.value = selectedKeys.map((val, i) => {
|
||||
const nu = toNumber(val);
|
||||
return props.activities.data[val].id;
|
||||
});
|
||||
|
||||
console.log(selectedRows.value);
|
||||
}
|
||||
|
||||
// Mark as read actions
|
||||
function markRead(id) {
|
||||
router.patch(
|
||||
route("notifications.activity.read"),
|
||||
|
|
@ -130,143 +132,131 @@ function markReadBulk() {
|
|||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
selectedRows.value = [];
|
||||
// Clear the selection state in DataTable
|
||||
if (dataTableRef.value) {
|
||||
dataTableRef.value.clearSelection();
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Table columns definition (select column is auto-generated by enableRowSelection)
|
||||
const columns = [
|
||||
{ key: "what", label: "Zadeva", sortable: false },
|
||||
{ key: "partner", label: "Partner", sortable: false },
|
||||
{ key: "balance", label: "Stanje", sortable: false, align: "right" },
|
||||
{ key: "due", label: "Zapadlost", sortable: false },
|
||||
{ key: "actions", label: "", sortable: false, hideable: false, align: "center" },
|
||||
];
|
||||
|
||||
const rows = computed(() => props.activities?.data || []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Obvestila">
|
||||
<template #header></template>
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<div class="mx-auto max-w-4x1 py-3">
|
||||
<div class="pb-3">
|
||||
<SectionTitle>
|
||||
<template #title>Neprikazana obvestila</template>
|
||||
<template #description>Do danes: {{ fmtDate(today) }}</template>
|
||||
</SectionTitle>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="flex-1 max-w-sm">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Partner</label
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10"
|
||||
>
|
||||
<Bell class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Neprikazana obvestila</CardTitle>
|
||||
<CardDescription>Do danes: {{ fmtDate(today) }}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<!-- Client Filter -->
|
||||
<div class="mb-6 px-6 flex items-end gap-3">
|
||||
<div class="flex-1 max-w-sm space-y-2">
|
||||
<Label for="client-filter">Partner</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
v-model="selectedClient"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
|
||||
>
|
||||
<option value="">Vsi partnerji</option>
|
||||
<option
|
||||
v-for="opt in clientOptions"
|
||||
:key="opt.value || opt.label"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
<Select v-model="selectedClient">
|
||||
<SelectTrigger id="client-filter">
|
||||
<SelectValue placeholder="Vsi partnerji" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="opt in clientOptions"
|
||||
:key="opt.value || opt.label"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
v-if="selectedClient"
|
||||
type="button"
|
||||
class="text-sm text-gray-600 hover:text-gray-900"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="selectedClient = ''"
|
||||
title="Počisti filter"
|
||||
>
|
||||
Počisti
|
||||
</button>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTableServer
|
||||
:columns="[
|
||||
{ key: 'select', label: '', sortable: false, width: '50px' },
|
||||
{ key: 'what', label: 'Zadeva', sortable: false },
|
||||
{ key: 'partner', label: 'Partner', sortable: false },
|
||||
{
|
||||
key: 'balance',
|
||||
label: 'Stanje',
|
||||
sortable: false,
|
||||
align: 'right',
|
||||
class: 'w-40',
|
||||
},
|
||||
{ key: 'due', label: 'Zapadlost', sortable: true, class: 'w-28' },
|
||||
]"
|
||||
:rows="activities.data || []"
|
||||
:meta="{
|
||||
current_page: activities.current_page,
|
||||
per_page: activities.per_page,
|
||||
total: activities.total,
|
||||
last_page: activities.last_page,
|
||||
}"
|
||||
<!-- Data Table -->
|
||||
<DataTable
|
||||
ref="dataTableRef"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:meta="activities"
|
||||
route-name="notifications.unread"
|
||||
page-param-name="unread-page"
|
||||
:only-props="['activities']"
|
||||
:query="{ client: selectedClient || undefined }"
|
||||
:page-size="15"
|
||||
:page-size-options="[10, 15, 25, 50]"
|
||||
:show-pagination="true"
|
||||
:show-toolbar="true"
|
||||
:hoverable="true"
|
||||
:enable-row-selection="true"
|
||||
row-key="id"
|
||||
empty-text="Trenutno ni neprikazanih obvestil."
|
||||
@selection:change="handleSelectionChange"
|
||||
>
|
||||
<template #toolbar-extra>
|
||||
<div v-if="selectedRows.length" class="flex items-center gap-2">
|
||||
<div class="text-sm text-gray-700">
|
||||
Izbrano: <span class="font-medium">{{ selectedRows.length }}</span>
|
||||
<!-- Bulk Actions Toolbar -->
|
||||
<template #toolbar-filters>
|
||||
<div v-if="selectedRows.length" class="flex items-center gap-3">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Izbrano:
|
||||
<span class="font-medium text-foreground">{{
|
||||
selectedRows.length
|
||||
}}</span>
|
||||
</div>
|
||||
<Dropdown width="48" align="left">
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md border border-gray-300 text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="gap-2">
|
||||
Akcije
|
||||
<svg
|
||||
class="ml-1 h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.06 1.06l-4.24 4.24a.75.75 0 01-1.06 0L5.21 8.29a.75.75 0 01.02-1.08z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
<template #content>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
@click="markReadBulk"
|
||||
>
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem @click="markReadBulk">
|
||||
<Check class="h-4 w-4" />
|
||||
Označi kot prebrano
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-select>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isAllSelected()"
|
||||
:indeterminate="isIndeterminate()"
|
||||
@change="toggleSelectAll"
|
||||
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-select="{ row }">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isRowSelected(row.id)"
|
||||
@change="toggleRowSelection(row.id)"
|
||||
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- What Column -->
|
||||
<template #cell-what="{ row }">
|
||||
<div class="font-medium text-gray-800 truncate">
|
||||
<div class="font-medium truncate">
|
||||
<template v-if="row.contract?.uuid">
|
||||
Pogodba:
|
||||
<span class="text-muted-foreground">Pogodba:</span>
|
||||
<Link
|
||||
v-if="row.contract?.client_case?.uuid"
|
||||
:href="
|
||||
|
|
@ -274,27 +264,31 @@ function markReadBulk() {
|
|||
client_case: row.contract.client_case.uuid,
|
||||
})
|
||||
"
|
||||
class="text-indigo-600 hover:underline"
|
||||
class="ml-1 text-primary hover:underline"
|
||||
>
|
||||
{{ row.contract?.reference || "—" }}
|
||||
</Link>
|
||||
<span v-else>{{ row.contract?.reference || "—" }}</span>
|
||||
<span v-else class="ml-1">{{ row.contract?.reference || "—" }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
Primer:
|
||||
<span class="text-muted-foreground">Primer:</span>
|
||||
<Link
|
||||
v-if="row.client_case?.uuid"
|
||||
:href="
|
||||
route('clientCase.show', { client_case: row.client_case.uuid })
|
||||
"
|
||||
class="text-indigo-600 hover:underline"
|
||||
class="ml-1 text-primary hover:underline"
|
||||
>
|
||||
{{ row.client_case?.person?.full_name || "—" }}
|
||||
</Link>
|
||||
<span v-else>{{ row.client_case?.person?.full_name || "—" }}</span>
|
||||
<span v-else class="ml-1">{{
|
||||
row.client_case?.person?.full_name || "—"
|
||||
}}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Partner Column -->
|
||||
<template #cell-partner="{ row }">
|
||||
<div class="truncate">
|
||||
{{
|
||||
|
|
@ -304,34 +298,51 @@ function markReadBulk() {
|
|||
}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Balance Column -->
|
||||
<template #cell-balance="{ row }">
|
||||
<div class="text-right">
|
||||
<div class="text-right font-medium">
|
||||
<span v-if="row.contract">{{
|
||||
fmtEUR(row.contract?.account?.balance_amount)
|
||||
}}</span>
|
||||
<span v-else>—</span>
|
||||
<span v-else class="text-muted-foreground">—</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Due Date Column -->
|
||||
<template #cell-due="{ row }">
|
||||
{{ fmtDate(row.due_date) }}
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<button
|
||||
type="button"
|
||||
class="text-[12px] text-gray-500 hover:text-gray-700"
|
||||
@click="markRead(row.id)"
|
||||
>
|
||||
Označi kot prebrano
|
||||
</button>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div class="p-6 text-center text-gray-500">
|
||||
Trenutno ni neprikazanih obvestil.
|
||||
<div class="text-sm">
|
||||
{{ fmtDate(row.due_date) }}
|
||||
</div>
|
||||
</template>
|
||||
</DataTableServer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<template #cell-actions="{ row }">
|
||||
<TableActions>
|
||||
<ActionMenuItem @click="markRead(row.id)" label="Označi kot prebrano">
|
||||
<BellOff class="mr-2 h-4 w-4" />
|
||||
Označi kot prebrano
|
||||
</ActionMenuItem>
|
||||
</TableActions>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template #empty>
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div
|
||||
class="flex h-20 w-20 items-center justify-center rounded-full bg-muted"
|
||||
>
|
||||
<Inbox class="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold">Ni neprikazanih obvestil</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Trenutno nimate nobenih neprikazanih obvestil.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -454,7 +454,7 @@ const clientSummary = computed(() => {
|
|||
:key="a.id"
|
||||
class="bg-gray-50/70 dark:bg-gray-800/50"
|
||||
>
|
||||
<CardHeader class="pb-3">
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<CardTitle class="text-sm font-medium truncate">
|
||||
{{ activityActionLine(a) || "Aktivnost" }}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import ActionSection from '@/Components/ActionSection.vue';
|
||||
import DangerButton from '@/Components/DangerButton.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/Components/ui/alert-dialog';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import { Trash2, AlertTriangle } from 'lucide-vue-next';
|
||||
|
||||
const confirmingUserDeletion = ref(false);
|
||||
const passwordInput = ref(null);
|
||||
|
|
@ -38,65 +38,68 @@ const closeModal = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<ActionSection>
|
||||
<template #title>
|
||||
Delete Account
|
||||
</template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<Trash2 class="h-5 w-5 text-destructive" />
|
||||
<CardTitle>Delete Account</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Permanently delete your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<template #description>
|
||||
Permanently delete your account.
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="max-w-xl text-sm text-gray-600">
|
||||
Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.
|
||||
<CardContent class="space-y-4">
|
||||
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||
<div class="flex gap-3">
|
||||
<AlertTriangle class="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-foreground">
|
||||
Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<DangerButton @click="confirmUserDeletion">
|
||||
Delete Account
|
||||
</DangerButton>
|
||||
</div>
|
||||
<Button variant="destructive" @click="confirmUserDeletion">
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Delete Account
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
<!-- Delete Account Confirmation Modal -->
|
||||
<DialogModal :show="confirmingUserDeletion" @close="closeModal">
|
||||
<template #title>
|
||||
Delete Account
|
||||
</template>
|
||||
<!-- Delete Account Confirmation Dialog -->
|
||||
<AlertDialog :open="confirmingUserDeletion" @update:open="closeModal">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Account</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<template #content>
|
||||
Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.
|
||||
<div class="py-4">
|
||||
<Input
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="deleteUser"
|
||||
/>
|
||||
<InputError :message="form.errors.password" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<TextInput
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-3/4"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="deleteUser"
|
||||
/>
|
||||
|
||||
<InputError :message="form.errors.password" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="closeModal">
|
||||
<AlertDialogFooter>
|
||||
<Button variant="outline" @click="closeModal">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<DangerButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
:disabled="form.processing"
|
||||
@click="deleteUser"
|
||||
>
|
||||
Delete Account
|
||||
</DangerButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
</ActionSection>
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,141 +1,159 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import ActionSection from '@/Components/ActionSection.vue';
|
||||
import DialogModal from '@/Components/DialogModal.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import { ref } from "vue";
|
||||
import { useForm } from "@inertiajs/vue3";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/Components/ui/dialog";
|
||||
import InputError from "@/Components/InputError.vue";
|
||||
import { Monitor, Smartphone, LogOut, CheckCircle } from "lucide-vue-next";
|
||||
|
||||
defineProps({
|
||||
sessions: Array,
|
||||
sessions: Array,
|
||||
});
|
||||
|
||||
const confirmingLogout = ref(false);
|
||||
const passwordInput = ref(null);
|
||||
|
||||
const form = useForm({
|
||||
password: '',
|
||||
password: "",
|
||||
});
|
||||
|
||||
const confirmLogout = () => {
|
||||
confirmingLogout.value = true;
|
||||
confirmingLogout.value = true;
|
||||
|
||||
setTimeout(() => passwordInput.value.focus(), 250);
|
||||
setTimeout(() => passwordInput.value.focus(), 250);
|
||||
};
|
||||
|
||||
const logoutOtherBrowserSessions = () => {
|
||||
form.delete(route('other-browser-sessions.destroy'), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => closeModal(),
|
||||
onError: () => passwordInput.value.focus(),
|
||||
onFinish: () => form.reset(),
|
||||
});
|
||||
form.delete(route("other-browser-sessions.destroy"), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => closeModal(),
|
||||
onError: () => passwordInput.value.focus(),
|
||||
onFinish: () => form.reset(),
|
||||
});
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
confirmingLogout.value = false;
|
||||
confirmingLogout.value = false;
|
||||
|
||||
form.reset();
|
||||
form.reset();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActionSection>
|
||||
<template #title>
|
||||
Browser Sessions
|
||||
</template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<LogOut class="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Browser Sessions</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Manage and log out your active sessions on other browsers and devices.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<template #description>
|
||||
Manage and log out your active sessions on other browsers and devices.
|
||||
</template>
|
||||
<CardContent class="space-y-6">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
If necessary, you may log out of all of your other browser sessions across all of
|
||||
your devices. Some of your recent sessions are listed below; however, this list
|
||||
may not be exhaustive. If you feel your account has been compromised, you should
|
||||
also update your password.
|
||||
</p>
|
||||
|
||||
<template #content>
|
||||
<div class="max-w-xl text-sm text-gray-600">
|
||||
If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.
|
||||
<!-- Other Browser Sessions -->
|
||||
<div v-if="sessions.length > 0" class="space-y-4">
|
||||
<div
|
||||
v-for="(session, i) in sessions"
|
||||
:key="i"
|
||||
class="flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div class="flex-shrink-0">
|
||||
<Monitor
|
||||
v-if="session.agent.is_desktop"
|
||||
class="h-8 w-8 text-muted-foreground"
|
||||
/>
|
||||
<Smartphone v-else class="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium">
|
||||
{{ session.agent.platform ? session.agent.platform : "Unknown" }} -
|
||||
{{ session.agent.browser ? session.agent.browser : "Unknown" }}
|
||||
</div>
|
||||
|
||||
<!-- Other Browser Sessions -->
|
||||
<div v-if="sessions.length > 0" class="mt-5 space-y-6">
|
||||
<div v-for="(session, i) in sessions" :key="i" class="flex items-center">
|
||||
<div>
|
||||
<svg v-if="session.agent.is_desktop" class="w-8 h-8 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
|
||||
</svg>
|
||||
|
||||
<svg v-else class="w-8 h-8 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="ms-3">
|
||||
<div class="text-sm text-gray-600">
|
||||
{{ session.agent.platform ? session.agent.platform : 'Unknown' }} - {{ session.agent.browser ? session.agent.browser : 'Unknown' }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ session.ip_address }},
|
||||
|
||||
<span v-if="session.is_current_device" class="text-green-500 font-semibold">This device</span>
|
||||
<span v-else>Last active {{ session.last_active }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
{{ session.ip_address }}
|
||||
<span
|
||||
v-if="session.is_current_device"
|
||||
class="inline-flex items-center ml-2 text-green-600 dark:text-green-400 font-semibold"
|
||||
>
|
||||
This device
|
||||
</span>
|
||||
<span v-else class="ml-1"> · Last active {{ session.last_active }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mt-5">
|
||||
<PrimaryButton @click="confirmLogout">
|
||||
Log Out Other Browser Sessions
|
||||
</PrimaryButton>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button @click="confirmLogout">
|
||||
<LogOut class="h-4 w-4 mr-2" />
|
||||
Log Out Other Browser Sessions
|
||||
</Button>
|
||||
|
||||
<ActionMessage :on="form.recentlySuccessful" class="ms-3">
|
||||
Done.
|
||||
</ActionMessage>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.recentlySuccessful"
|
||||
class="flex items-center gap-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle class="h-4 w-4 text-green-600" />
|
||||
<span>Done.</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<!-- Log Out Other Devices Confirmation Modal -->
|
||||
<DialogModal :show="confirmingLogout" @close="closeModal">
|
||||
<template #title>
|
||||
Log Out Other Browser Sessions
|
||||
</template>
|
||||
<!-- Log Out Other Devices Confirmation Dialog -->
|
||||
<Dialog :open="confirmingLogout" @update:open="closeModal">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Log Out Other Browser Sessions</DialogTitle>
|
||||
<DialogDescription>
|
||||
Please enter your password to confirm you would like to log out of your other
|
||||
browser sessions across all of your devices.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<template #content>
|
||||
Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.
|
||||
<div class="py-4">
|
||||
<Input
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="logoutOtherBrowserSessions"
|
||||
/>
|
||||
<InputError :message="form.errors.password" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<TextInput
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-3/4"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="logoutOtherBrowserSessions"
|
||||
/>
|
||||
|
||||
<InputError :message="form.errors.password" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="closeModal">
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
|
||||
<PrimaryButton
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': form.processing }"
|
||||
:disabled="form.processing"
|
||||
@click="logoutOtherBrowserSessions"
|
||||
>
|
||||
Log Out Other Browser Sessions
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
</ActionSection>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="closeModal"> Cancel </Button>
|
||||
<Button :disabled="form.processing" @click="logoutOtherBrowserSessions">
|
||||
Log Out Other Browser Sessions
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { router, useForm, usePage } from '@inertiajs/vue3';
|
||||
import ActionSection from '@/Components/ActionSection.vue';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import ConfirmsPassword from '@/Components/ConfirmsPassword.vue';
|
||||
import DangerButton from '@/Components/DangerButton.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import { Shield, Key, Copy, RefreshCw, CheckCircle, AlertCircle } from 'lucide-vue-next';
|
||||
|
||||
const props = defineProps({
|
||||
requiresConfirmation: Boolean,
|
||||
|
|
@ -102,152 +102,205 @@ const disableTwoFactorAuthentication = () => {
|
|||
},
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActionSection>
|
||||
<template #title>
|
||||
Two Factor Authentication
|
||||
</template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<Shield class="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Two Factor Authentication</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Add additional security to your account using two factor authentication.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<template #description>
|
||||
Add additional security to your account using two factor authentication.
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<h3 v-if="twoFactorEnabled && ! confirming" class="text-lg font-medium text-gray-900">
|
||||
You have enabled two factor authentication.
|
||||
</h3>
|
||||
|
||||
<h3 v-else-if="twoFactorEnabled && confirming" class="text-lg font-medium text-gray-900">
|
||||
Finish enabling two factor authentication.
|
||||
</h3>
|
||||
|
||||
<h3 v-else class="text-lg font-medium text-gray-900">
|
||||
You have not enabled two factor authentication.
|
||||
</h3>
|
||||
|
||||
<div class="mt-3 max-w-xl text-sm text-gray-600">
|
||||
<p>
|
||||
When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application.
|
||||
</p>
|
||||
<CardContent class="space-y-6">
|
||||
<!-- Status Header -->
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1">
|
||||
<h3 v-if="twoFactorEnabled && ! confirming" class="text-lg font-semibold flex items-center gap-2">
|
||||
<CheckCircle class="h-5 w-5 text-green-600" />
|
||||
Two factor authentication is enabled
|
||||
</h3>
|
||||
<h3 v-else-if="twoFactorEnabled && confirming" class="text-lg font-semibold flex items-center gap-2">
|
||||
<AlertCircle class="h-5 w-5 text-amber-600" />
|
||||
Finish enabling two factor authentication
|
||||
</h3>
|
||||
<h3 v-else class="text-lg font-semibold flex items-center gap-2">
|
||||
<Shield class="h-5 w-5 text-muted-foreground" />
|
||||
Two factor authentication is disabled
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="twoFactorEnabled">
|
||||
<div v-if="qrCode">
|
||||
<div class="mt-4 max-w-xl text-sm text-gray-600">
|
||||
<p v-if="confirming" class="font-semibold">
|
||||
<!-- QR Code & Setup -->
|
||||
<div v-if="twoFactorEnabled" class="space-y-6">
|
||||
<div v-if="qrCode" class="space-y-4">
|
||||
<div class="rounded-lg border bg-muted/50 p-4">
|
||||
<p v-if="confirming" class="text-sm font-medium mb-4">
|
||||
To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code.
|
||||
</p>
|
||||
|
||||
<p v-else>
|
||||
<p v-else class="text-sm text-muted-foreground mb-4">
|
||||
Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.
|
||||
</p>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div class="flex justify-center p-4 bg-white rounded-lg" v-html="qrCode" />
|
||||
|
||||
<!-- Setup Key -->
|
||||
<div v-if="setupKey" class="mt-4 p-3 bg-background rounded-lg border">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<Label class="text-xs text-muted-foreground">Setup Key</Label>
|
||||
<p class="font-mono text-sm font-semibold mt-1" v-html="setupKey"></p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="copyToClipboard(setupKey)"
|
||||
>
|
||||
<Copy class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-2 inline-block bg-white" v-html="qrCode" />
|
||||
|
||||
<div v-if="setupKey" class="mt-4 max-w-xl text-sm text-gray-600">
|
||||
<p class="font-semibold">
|
||||
Setup Key: <span v-html="setupKey"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="confirming" class="mt-4">
|
||||
<InputLabel for="code" value="Code" />
|
||||
|
||||
<TextInput
|
||||
<!-- Confirmation Code Input -->
|
||||
<div v-if="confirming" class="space-y-2">
|
||||
<Label for="code">Confirmation Code</Label>
|
||||
<Input
|
||||
id="code"
|
||||
v-model="confirmationForm.code"
|
||||
type="text"
|
||||
name="code"
|
||||
class="block mt-1 w-1/2"
|
||||
inputmode="numeric"
|
||||
autofocus
|
||||
autocomplete="one-time-code"
|
||||
placeholder="Enter 6-digit code"
|
||||
class="max-w-xs"
|
||||
@keyup.enter="confirmTwoFactorAuthentication"
|
||||
/>
|
||||
|
||||
<InputError :message="confirmationForm.errors.code" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="recoveryCodes.length > 0 && ! confirming">
|
||||
<div class="mt-4 max-w-xl text-sm text-gray-600">
|
||||
<p class="font-semibold">
|
||||
Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.
|
||||
</p>
|
||||
<!-- Recovery Codes -->
|
||||
<div v-if="recoveryCodes.length > 0 && ! confirming" class="space-y-4">
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950">
|
||||
<div class="flex items-start gap-2">
|
||||
<Key class="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-1 max-w-xl mt-4 px-4 py-4 font-mono text-sm bg-gray-100 rounded-lg">
|
||||
<div v-for="code in recoveryCodes" :key="code">
|
||||
{{ code }}
|
||||
<div class="rounded-lg border bg-muted p-4">
|
||||
<div class="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||
<div v-for="code in recoveryCodes" :key="code" class="flex items-center justify-between p-2 bg-background rounded border">
|
||||
<span>{{ code }}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 w-6 p-0"
|
||||
@click="copyToClipboard(code)"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- Enable -->
|
||||
<div v-if="! twoFactorEnabled">
|
||||
<ConfirmsPassword @confirmed="enableTwoFactorAuthentication">
|
||||
<PrimaryButton type="button" :class="{ 'opacity-25': enabling }" :disabled="enabling">
|
||||
<Button type="button" :disabled="enabling">
|
||||
<Shield class="h-4 w-4 mr-2" />
|
||||
Enable
|
||||
</PrimaryButton>
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Confirm -->
|
||||
<template v-else>
|
||||
<ConfirmsPassword @confirmed="confirmTwoFactorAuthentication">
|
||||
<PrimaryButton
|
||||
<Button
|
||||
v-if="confirming"
|
||||
type="button"
|
||||
class="me-3"
|
||||
:class="{ 'opacity-25': enabling }"
|
||||
:disabled="enabling"
|
||||
>
|
||||
<CheckCircle class="h-4 w-4 mr-2" />
|
||||
Confirm
|
||||
</PrimaryButton>
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<!-- Regenerate Recovery Codes -->
|
||||
<ConfirmsPassword @confirmed="regenerateRecoveryCodes">
|
||||
<SecondaryButton
|
||||
<Button
|
||||
v-if="recoveryCodes.length > 0 && ! confirming"
|
||||
class="me-3"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4 mr-2" />
|
||||
Regenerate Recovery Codes
|
||||
</SecondaryButton>
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<!-- Show Recovery Codes -->
|
||||
<ConfirmsPassword @confirmed="showRecoveryCodes">
|
||||
<SecondaryButton
|
||||
<Button
|
||||
v-if="recoveryCodes.length === 0 && ! confirming"
|
||||
class="me-3"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Key class="h-4 w-4 mr-2" />
|
||||
Show Recovery Codes
|
||||
</SecondaryButton>
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<!-- Cancel/Disable -->
|
||||
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
||||
<SecondaryButton
|
||||
<Button
|
||||
v-if="confirming"
|
||||
:class="{ 'opacity-25': disabling }"
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="disabling"
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
|
||||
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
||||
<DangerButton
|
||||
<Button
|
||||
v-if="! confirming"
|
||||
:class="{ 'opacity-25': disabling }"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
:disabled="disabling"
|
||||
>
|
||||
Disable
|
||||
</DangerButton>
|
||||
</Button>
|
||||
</ConfirmsPassword>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</ActionSection>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import { CheckCircle, Lock } from 'lucide-vue-next';
|
||||
|
||||
const passwordInput = ref(null);
|
||||
const currentPasswordInput = ref(null);
|
||||
|
|
@ -38,63 +38,64 @@ const updatePassword = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<FormSection @submitted="updatePassword">
|
||||
<template #title>
|
||||
Update Password
|
||||
</template>
|
||||
<Card>
|
||||
<form @submit.prevent="updatePassword">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<Lock class="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Update Password</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Ensure your account is using a long, random password to stay secure.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<template #description>
|
||||
Ensure your account is using a long, random password to stay secure.
|
||||
</template>
|
||||
<CardContent class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<Label for="current_password">Current Password</Label>
|
||||
<Input
|
||||
id="current_password"
|
||||
ref="currentPasswordInput"
|
||||
v-model="form.current_password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<InputError :message="form.errors.current_password" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<template #form>
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="current_password" value="Current Password" />
|
||||
<TextInput
|
||||
id="current_password"
|
||||
ref="currentPasswordInput"
|
||||
v-model="form.current_password"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<InputError :message="form.errors.current_password" class="mt-2" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="password">New Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<InputError :message="form.errors.password" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="password" value="New Password" />
|
||||
<TextInput
|
||||
id="password"
|
||||
ref="passwordInput"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<InputError :message="form.errors.password" class="mt-2" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="password_confirmation">Confirm Password</Label>
|
||||
<Input
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<InputError :message="form.errors.password_confirmation" class="mt-2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="password_confirmation" value="Confirm Password" />
|
||||
<TextInput
|
||||
id="password_confirmation"
|
||||
v-model="form.password_confirmation"
|
||||
type="password"
|
||||
class="mt-1 block w-full"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<InputError :message="form.errors.password_confirmation" class="mt-2" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<ActionMessage :on="form.recentlySuccessful" class="me-3">
|
||||
Saved.
|
||||
</ActionMessage>
|
||||
|
||||
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
||||
Save
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
<CardFooter class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" />
|
||||
<span v-if="form.recentlySuccessful">Saved.</span>
|
||||
</div>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
Save
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { Link, router, useForm } from '@inertiajs/vue3';
|
||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/Components/ui/avatar';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
||||
import TextInput from '@/Components/TextInput.vue';
|
||||
import { User, Mail, Camera, Trash2, CheckCircle, AlertCircle } from 'lucide-vue-next';
|
||||
|
||||
const props = defineProps({
|
||||
user: Object,
|
||||
|
|
@ -76,115 +76,138 @@ const clearPhotoFileInput = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<FormSection @submitted="updateProfileInformation">
|
||||
<template #title>
|
||||
Profile Information
|
||||
</template>
|
||||
<Card>
|
||||
<form @submit.prevent="updateProfileInformation">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<User class="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Profile Information</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Update your account's profile information and email address.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<template #description>
|
||||
Update your account's profile information and email address.
|
||||
</template>
|
||||
<CardContent class="space-y-6">
|
||||
<!-- Profile Photo -->
|
||||
<div v-if="$page.props.jetstream.managesProfilePhotos" class="space-y-4">
|
||||
<input
|
||||
id="photo"
|
||||
ref="photoInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="image/*"
|
||||
@change="updatePhotoPreview"
|
||||
>
|
||||
|
||||
<template #form>
|
||||
<!-- Profile Photo -->
|
||||
<div v-if="$page.props.jetstream.managesProfilePhotos" class="col-span-6 sm:col-span-4">
|
||||
<!-- Profile Photo File Input -->
|
||||
<input
|
||||
id="photo"
|
||||
ref="photoInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="updatePhotoPreview"
|
||||
>
|
||||
<Label for="photo">Photo</Label>
|
||||
|
||||
<InputLabel for="photo" value="Photo" />
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Current/Preview Photo -->
|
||||
<Avatar class="h-20 w-20">
|
||||
<AvatarImage
|
||||
v-if="photoPreview"
|
||||
:src="photoPreview"
|
||||
:alt="user.name"
|
||||
/>
|
||||
<AvatarImage
|
||||
v-else
|
||||
:src="user.profile_photo_url"
|
||||
:alt="user.name"
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<User class="h-8 w-8" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<!-- Current Profile Photo -->
|
||||
<div v-show="! photoPreview" class="mt-2">
|
||||
<img :src="user.profile_photo_url" :alt="user.name" class="rounded-full h-20 w-20 object-cover">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click.prevent="selectNewPhoto"
|
||||
>
|
||||
<Camera class="h-4 w-4 mr-2" />
|
||||
Select Photo
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="user.profile_photo_path"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click.prevent="deletePhoto"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputError :message="form.errors.photo" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- New Profile Photo Preview -->
|
||||
<div v-show="photoPreview" class="mt-2">
|
||||
<span
|
||||
class="block rounded-full w-20 h-20 bg-cover bg-no-repeat bg-center"
|
||||
:style="'background-image: url(\'' + photoPreview + '\');'"
|
||||
<!-- Name -->
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="name"
|
||||
/>
|
||||
<InputError :message="form.errors.name" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<SecondaryButton class="mt-2 me-2" type="button" @click.prevent="selectNewPhoto">
|
||||
Select A New Photo
|
||||
</SecondaryButton>
|
||||
<!-- Email -->
|
||||
<div class="space-y-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="username"
|
||||
/>
|
||||
<InputError :message="form.errors.email" class="mt-2" />
|
||||
|
||||
<SecondaryButton
|
||||
v-if="user.profile_photo_path"
|
||||
type="button"
|
||||
class="mt-2"
|
||||
@click.prevent="deletePhoto"
|
||||
>
|
||||
Remove Photo
|
||||
</SecondaryButton>
|
||||
|
||||
<InputError :message="form.errors.photo" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="name" value="Name" />
|
||||
<TextInput
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="name"
|
||||
/>
|
||||
<InputError :message="form.errors.name" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="email" value="Email" />
|
||||
<TextInput
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="username"
|
||||
/>
|
||||
<InputError :message="form.errors.email" class="mt-2" />
|
||||
|
||||
<div v-if="$page.props.jetstream.hasEmailVerification && user.email_verified_at === null">
|
||||
<p class="text-sm mt-2">
|
||||
Your email address is unverified.
|
||||
|
||||
<Link
|
||||
:href="route('verification.send')"
|
||||
method="post"
|
||||
as="button"
|
||||
class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
@click.prevent="sendEmailVerification"
|
||||
>
|
||||
Click here to re-send the verification email.
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div v-show="verificationLinkSent" class="mt-2 font-medium text-sm text-green-600">
|
||||
A new verification link has been sent to your email address.
|
||||
<!-- Email Verification -->
|
||||
<div v-if="$page.props.jetstream.hasEmailVerification && user.email_verified_at === null" class="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-950">
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertCircle class="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5" />
|
||||
<div class="flex-1 text-sm">
|
||||
<p class="text-amber-800 dark:text-amber-200">
|
||||
Your email address is unverified.
|
||||
<Link
|
||||
:href="route('verification.send')"
|
||||
method="post"
|
||||
as="button"
|
||||
class="underline text-amber-900 hover:text-amber-700 dark:text-amber-100 dark:hover:text-amber-300 font-medium"
|
||||
@click.prevent="sendEmailVerification"
|
||||
>
|
||||
Click here to re-send the verification email.
|
||||
</Link>
|
||||
</p>
|
||||
<div v-show="verificationLinkSent" class="mt-2 flex items-center gap-1.5 text-green-700 dark:text-green-400">
|
||||
<CheckCircle class="h-4 w-4" />
|
||||
<span>A new verification link has been sent to your email address.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardContent>
|
||||
|
||||
<template #actions>
|
||||
<ActionMessage :on="form.recentlySuccessful" class="me-3">
|
||||
Saved.
|
||||
</ActionMessage>
|
||||
|
||||
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
||||
Save
|
||||
</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
<CardFooter class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" />
|
||||
<span v-if="form.recentlySuccessful">Saved.</span>
|
||||
</div>
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
Save
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import DeleteUserForm from '@/Pages/Profile/Partials/DeleteUserForm.vue';
|
||||
import LogoutOtherBrowserSessionsForm from '@/Pages/Profile/Partials/LogoutOtherBrowserSessionsForm.vue';
|
||||
import SectionBorder from '@/Components/SectionBorder.vue';
|
||||
import { Separator } from '@/Components/ui/separator';
|
||||
import TwoFactorAuthenticationForm from '@/Pages/Profile/Partials/TwoFactorAuthenticationForm.vue';
|
||||
import UpdatePasswordForm from '@/Pages/Profile/Partials/UpdatePasswordForm.vue';
|
||||
import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfileInformationForm.vue';
|
||||
|
|
@ -26,13 +26,13 @@ defineProps({
|
|||
<div v-if="$page.props.jetstream.canUpdateProfileInformation">
|
||||
<UpdateProfileInformationForm :user="$page.props.auth.user" />
|
||||
|
||||
<SectionBorder />
|
||||
<Separator class="my-10" />
|
||||
</div>
|
||||
|
||||
<div v-if="$page.props.jetstream.canUpdatePassword">
|
||||
<UpdatePasswordForm class="mt-10 sm:mt-0" />
|
||||
|
||||
<SectionBorder />
|
||||
<Separator class="my-10" />
|
||||
</div>
|
||||
|
||||
<div v-if="$page.props.jetstream.canManageTwoFactorAuthentication">
|
||||
|
|
@ -41,7 +41,7 @@ defineProps({
|
|||
class="mt-10 sm:mt-0"
|
||||
/>
|
||||
|
||||
<SectionBorder />
|
||||
<Separator class="my-10" />
|
||||
</div>
|
||||
|
||||
<LogoutOtherBrowserSessionsForm :sessions="sessions" class="mt-10 sm:mt-0" />
|
||||
|
|
|
|||
|
|
@ -516,6 +516,9 @@ function extractFilenameFromHeaders(headers) {
|
|||
</Link>
|
||||
<span v-else>{{ row.client_case?.person?.full_name || "-" }}</span>
|
||||
</template>
|
||||
<template #cell-address="{ row }">
|
||||
{{ row.client_case?.person?.address?.address }}
|
||||
</template>
|
||||
<template #cell-client="{ row }">
|
||||
{{ row.client?.person?.full_name || "-" }}
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@
|
|||
|
||||
// Packages (batch jobs)
|
||||
Route::get('packages', [\App\Http\Controllers\Admin\PackageController::class, 'index'])->name('packages.index');
|
||||
Route::get('packages/create', [\App\Http\Controllers\Admin\PackageController::class, 'create'])->name('packages.create');
|
||||
Route::get('packages/{package}', [\App\Http\Controllers\Admin\PackageController::class, 'show'])->name('packages.show');
|
||||
Route::post('packages', [\App\Http\Controllers\Admin\PackageController::class, 'store'])->name('packages.store');
|
||||
Route::post('packages/{package}/dispatch', [\App\Http\Controllers\Admin\PackageController::class, 'dispatch'])->name('packages.dispatch');
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user