production #1
|
|
@ -26,6 +26,14 @@ public function index(Request $request): Response
|
||||||
$packages = Package::query()
|
$packages = Package::query()
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->paginate(25);
|
->paginate(25);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Packages/Index', [
|
||||||
|
'packages' => $packages,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): Response
|
||||||
|
{
|
||||||
// Minimal lookups for create form (active only)
|
// Minimal lookups for create form (active only)
|
||||||
$profiles = \App\Models\SmsProfile::query()
|
$profiles = \App\Models\SmsProfile::query()
|
||||||
->where('active', true)
|
->where('active', true)
|
||||||
|
|
@ -58,8 +66,7 @@ public function index(Request $request): Response
|
||||||
})
|
})
|
||||||
->values();
|
->values();
|
||||||
|
|
||||||
return Inertia::render('Admin/Packages/Index', [
|
return Inertia::render('Admin/Packages/Create', [
|
||||||
'packages' => $packages,
|
|
||||||
'profiles' => $profiles,
|
'profiles' => $profiles,
|
||||||
'senders' => $senders,
|
'senders' => $senders,
|
||||||
'templates' => $templates,
|
'templates' => $templates,
|
||||||
|
|
@ -312,7 +319,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
'segment_id' => ['nullable', 'integer', 'exists:segments,id'],
|
||||||
'q' => ['nullable', 'string'],
|
'q' => ['nullable', 'string'],
|
||||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
|
||||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||||
'only_mobile' => ['nullable', 'boolean'],
|
'only_mobile' => ['nullable', 'boolean'],
|
||||||
'only_validated' => ['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;
|
$segmentId = $request->input('segment_id') ? (int) $request->input('segment_id') : null;
|
||||||
$perPage = (int) ($request->input('per_page') ?? 25);
|
|
||||||
|
|
||||||
$query = Contract::query()
|
$query = Contract::query()
|
||||||
->with([
|
->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;
|
$person = $contract->clientCase?->person;
|
||||||
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
|
$selected = $person ? $selector->selectForPerson($person) : ['phone' => null, 'reason' => 'no_person'];
|
||||||
$phone = $selected['phone'];
|
$phone = $selected['phone'];
|
||||||
|
|
@ -431,13 +438,7 @@ public function contracts(Request $request, PhoneSelector $selector): \Illuminat
|
||||||
});
|
});
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $data,
|
'data' => $data
|
||||||
'meta' => [
|
|
||||||
'current_page' => $contracts->currentPage(),
|
|
||||||
'last_page' => $contracts->lastPage(),
|
|
||||||
'per_page' => $contracts->perPage(),
|
|
||||||
'total' => $contracts->total(),
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,9 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||||
'action_id' => 'exists:\App\Models\Action,id',
|
'action_id' => 'exists:\App\Models\Action,id',
|
||||||
'decision_id' => 'exists:\App\Models\Decision,id',
|
'decision_id' => 'exists:\App\Models\Decision,id',
|
||||||
'contract_uuid' => 'nullable|uuid',
|
'contract_uuid' => 'nullable|uuid',
|
||||||
|
'contract_uuids' => 'nullable|array',
|
||||||
|
'contract_uuids.*' => 'uuid',
|
||||||
|
'create_for_all_contracts' => 'nullable|boolean',
|
||||||
'phone_view' => 'nullable|boolean',
|
'phone_view' => 'nullable|boolean',
|
||||||
'send_auto_mail' => 'sometimes|boolean',
|
'send_auto_mail' => 'sometimes|boolean',
|
||||||
'attachment_document_ids' => 'sometimes|array',
|
'attachment_document_ids' => 'sometimes|array',
|
||||||
|
|
@ -318,61 +321,102 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$isPhoneView = $attributes['phone_view'] ?? false;
|
$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
|
// Determine which contracts to process
|
||||||
$contractId = null;
|
$contractIds = [];
|
||||||
if (! empty($attributes['contract_uuid'])) {
|
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()
|
$contract = Contract::withTrashed()
|
||||||
->where('uuid', $attributes['contract_uuid'])
|
->where('uuid', $attributes['contract_uuid'])
|
||||||
->where('client_case_id', $clientCase->id)
|
->where('client_case_id', $clientCase->id)
|
||||||
->first();
|
->first();
|
||||||
if ($contract) {
|
if ($contract) {
|
||||||
// Archived contracts are allowed: link activity regardless of active flag
|
$contractIds = [$contract->id];
|
||||||
$contractId = $contract->id;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create activity
|
// If no contracts specified, create a single activity without contract
|
||||||
$row = $clientCase->activities()->create([
|
if (empty($contractIds)) {
|
||||||
'due_date' => $attributes['due_date'] ?? null,
|
$contractIds = [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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger()->info('Activity successfully inserted', $attributes);
|
$createdActivities = [];
|
||||||
|
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
||||||
|
|
||||||
// Auto mail dispatch (best-effort)
|
// Disable auto mail if creating activities for multiple contracts
|
||||||
try {
|
if ($sendFlag && count($contractIds) > 1) {
|
||||||
$sendFlag = (bool) ($attributes['send_auto_mail'] ?? true);
|
$sendFlag = false;
|
||||||
$row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']);
|
logger()->info('Auto mail disabled: multiple contracts selected', ['contract_count' => count($contractIds)]);
|
||||||
// Filter attachments to those belonging to the selected contract
|
}
|
||||||
$attachmentIds = collect($attributes['attachment_document_ids'] ?? [])
|
|
||||||
->filter()
|
foreach ($contractIds as $contractId) {
|
||||||
->map(fn ($v) => (int) $v)
|
// Create activity
|
||||||
->values();
|
$row = $clientCase->activities()->create([
|
||||||
$validAttachmentIds = collect();
|
'due_date' => $attributes['due_date'] ?? null,
|
||||||
if ($attachmentIds->isNotEmpty() && $contractId) {
|
'amount' => $attributes['amount'] ?? null,
|
||||||
$validAttachmentIds = Document::query()
|
'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_type', Contract::class)
|
||||||
->where('documentable_id', $contractId)
|
->where('documentable_id', $contractId)
|
||||||
->whereIn('id', $attachmentIds)
|
->whereIn('id', $attachmentIds)
|
||||||
|
|
@ -383,19 +427,25 @@ public function storeActivity(ClientCase $clientCase, Request $request)
|
||||||
]);
|
]);
|
||||||
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
|
if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) {
|
||||||
// If template requires contract and user attempted to send, surface a validation message
|
// 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) {
|
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) {
|
} catch (\Throwable $e) {
|
||||||
// Do not fail activity creation due to mailing issues
|
// Do not fail activity creation due to mailing issues
|
||||||
logger()->warning('Auto mail dispatch failed: '.$e->getMessage());
|
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.
|
// 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.
|
// 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) {
|
} catch (QueryException $e) {
|
||||||
logger()->error('Database error occurred:', ['error' => $e->getMessage()]);
|
logger()->error('Database error occurred:', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -462,6 +462,17 @@ function keyOf(row) {
|
||||||
return row[props.rowKey];
|
return row[props.rowKey];
|
||||||
return row?.uuid ?? row?.id ?? Math.random().toString(36).slice(2);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<script setup>
|
||||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||||
import { Link, router, useForm } from "@inertiajs/vue3";
|
import { Link, router } from "@inertiajs/vue3";
|
||||||
import { ref, computed } from "vue";
|
import { ref } from "vue";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/Components/ui/card";
|
import { Card, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||||
import { Button } from "@/Components/ui/button";
|
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 { Badge } from "@/Components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Table,
|
AlertDialog,
|
||||||
TableBody,
|
AlertDialogAction,
|
||||||
TableCell,
|
AlertDialogCancel,
|
||||||
TableHead,
|
AlertDialogContent,
|
||||||
TableHeader,
|
AlertDialogDescription,
|
||||||
TableRow,
|
AlertDialogFooter,
|
||||||
} from "@/Components/ui/table";
|
AlertDialogHeader,
|
||||||
import { Separator } from "@/Components/ui/separator";
|
AlertDialogTitle,
|
||||||
|
} from "@/Components/ui/alert-dialog";
|
||||||
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
|
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
|
||||||
import Pagination from "@/Components/Pagination.vue";
|
import { PackageIcon, PlusIcon, Trash2Icon, EyeIcon } from "lucide-vue-next";
|
||||||
import {
|
|
||||||
PackageIcon,
|
|
||||||
PlusIcon,
|
|
||||||
XIcon,
|
|
||||||
SearchIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
EyeIcon,
|
|
||||||
} from "lucide-vue-next";
|
|
||||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
packages: { type: Object, required: true },
|
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 deletingId = ref(null);
|
||||||
const creatingFromContracts = ref(false);
|
const packageToDelete = ref(null);
|
||||||
|
const showDeleteDialog = ref(false);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ accessorKey: "id", header: "ID" },
|
{ accessorKey: "id", header: "ID" },
|
||||||
|
|
@ -73,242 +51,23 @@ function goShow(id) {
|
||||||
router.visit(route("admin.packages.show", id));
|
router.visit(route("admin.packages.show", id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const showCreate = ref(false);
|
function openDeleteDialog(pkg) {
|
||||||
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) {
|
|
||||||
if (!pkg || pkg.status !== "draft") return;
|
if (!pkg || pkg.status !== "draft") return;
|
||||||
if (!confirm(`Izbrišem paket #${pkg.id}?`)) return;
|
packageToDelete.value = pkg;
|
||||||
deletingId.value = pkg.id;
|
showDeleteDialog.value = true;
|
||||||
router.delete(route("admin.packages.destroy", pkg.id), {
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (!packageToDelete.value) return;
|
||||||
|
deletingId.value = packageToDelete.value.id;
|
||||||
|
router.delete(route("admin.packages.destroy", packageToDelete.value.id), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
router.reload({ only: ["packages"] });
|
router.reload({ only: ["packages"] });
|
||||||
},
|
},
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
deletingId.value = null;
|
deletingId.value = null;
|
||||||
},
|
showDeleteDialog.value = false;
|
||||||
});
|
packageToDelete.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;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -323,444 +82,16 @@ function submitCreateFromContracts() {
|
||||||
<PackageIcon class="h-5 w-5 text-muted-foreground" />
|
<PackageIcon class="h-5 w-5 text-muted-foreground" />
|
||||||
<CardTitle>SMS paketi</CardTitle>
|
<CardTitle>SMS paketi</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Link :href="route('admin.packages.create')">
|
||||||
@click="showCreate = !showCreate"
|
<Button>
|
||||||
:variant="showCreate ? 'outline' : 'default'"
|
<PlusIcon class="h-4 w-4 mr-2" />
|
||||||
>
|
Nov paket
|
||||||
<component :is="showCreate ? XIcon : PlusIcon" class="h-4 w-4 mr-2" />
|
</Button>
|
||||||
{{ showCreate ? "Zapri" : "Nov paket" }}
|
</Link>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</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
|
<AppCard
|
||||||
title=""
|
title=""
|
||||||
padding="none"
|
padding="none"
|
||||||
|
|
@ -771,7 +102,7 @@ function submitCreateFromContracts() {
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<PackageIcon size="18" />
|
<PackageIcon size="18" />
|
||||||
<CardTitle class="uppercase">Uvozi</CardTitle>
|
<CardTitle class="uppercase">Paketi</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<DataTableNew2
|
<DataTableNew2
|
||||||
|
|
@ -807,7 +138,7 @@ function submitCreateFromContracts() {
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="row.status === 'draft'"
|
v-if="row.status === 'draft'"
|
||||||
@click="deletePackage(row)"
|
@click="openDeleteDialog(row)"
|
||||||
:disabled="deletingId === row.id"
|
:disabled="deletingId === row.id"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -818,5 +149,28 @@ function submitCreateFromContracts() {
|
||||||
</template>
|
</template>
|
||||||
</DataTableNew2>
|
</DataTableNew2>
|
||||||
</AppCard>
|
</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>
|
</AdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/Components/ui/select";
|
} from "@/Components/ui/select";
|
||||||
import { Switch } from "@/Components/ui/switch";
|
import { Switch } from "@/Components/ui/switch";
|
||||||
|
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||||
import { ref, watch, computed } from "vue";
|
import { ref, watch, computed } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|
@ -53,7 +54,7 @@ const form = useInertiaForm({
|
||||||
props.actions[0].decisions.length > 0
|
props.actions[0].decisions.length > 0
|
||||||
? props.actions[0].decisions[0].id
|
? props.actions[0].decisions[0].id
|
||||||
: null,
|
: null,
|
||||||
contract_uuid: props.contractUuid,
|
contract_uuids: props.contractUuid ? [props.contractUuid] : [],
|
||||||
send_auto_mail: true,
|
send_auto_mail: true,
|
||||||
attach_documents: false,
|
attach_documents: false,
|
||||||
attachment_document_ids: [],
|
attachment_document_ids: [],
|
||||||
|
|
@ -95,7 +96,7 @@ watch(
|
||||||
watch(
|
watch(
|
||||||
() => props.contractUuid,
|
() => props.contractUuid,
|
||||||
(cu) => {
|
(cu) => {
|
||||||
form.contract_uuid = cu || null;
|
form.contract_uuids = cu ? [cu] : [];
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -103,7 +104,7 @@ watch(
|
||||||
() => props.show,
|
() => props.show,
|
||||||
(visible) => {
|
(visible) => {
|
||||||
if (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}`;
|
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
|
form
|
||||||
.transform((data) => ({
|
.transform((data) => ({
|
||||||
...data,
|
...data,
|
||||||
phone_view: props.phoneMode,
|
phone_view: props.phoneMode,
|
||||||
due_date: formatDateForSubmit(data.due_date),
|
due_date: formatDateForSubmit(data.due_date),
|
||||||
|
contract_uuids: contractUuids,
|
||||||
|
create_for_all_contracts: isMultipleContracts,
|
||||||
attachment_document_ids:
|
attachment_document_ids:
|
||||||
templateAllowsAttachments.value && data.attach_documents
|
templateAllowsAttachments.value && data.attach_documents && !isMultipleContracts
|
||||||
? data.attachment_document_ids
|
? data.attachment_document_ids
|
||||||
: [],
|
: [],
|
||||||
}))
|
}))
|
||||||
.post(route("clientCase.activity.store", props.client_case), {
|
.post(route("clientCase.activity.store", props.client_case), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
close();
|
close();
|
||||||
form.reset("due_date", "amount", "note");
|
form.reset("due_date", "amount", "note", "contract_uuids");
|
||||||
emit("saved");
|
emit("saved");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -165,13 +174,39 @@ const autoMailRequiresContract = computed(() => {
|
||||||
return types.includes("contract");
|
return types.includes("contract");
|
||||||
});
|
});
|
||||||
|
|
||||||
const autoMailDisabled = computed(() => {
|
const contractItems = computed(() => {
|
||||||
return showSendAutoMail() && autoMailRequiresContract.value && !form.contract_uuid;
|
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(() => {
|
const autoMailDisabledHint = computed(() => {
|
||||||
return autoMailDisabled.value
|
if (!showSendAutoMail()) return "";
|
||||||
? "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo."
|
|
||||||
: "";
|
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(
|
watch(
|
||||||
() => autoMailDisabled.value,
|
() => autoMailDisabled.value,
|
||||||
|
|
@ -231,9 +266,12 @@ const docsSource = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const availableContractDocs = 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 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;
|
if (!props.phoneMode) return all;
|
||||||
return all.filter((d) => {
|
return all.filter((d) => {
|
||||||
const mime = (d.mime_type || "").toLowerCase();
|
const mime = (d.mime_type || "").toLowerCase();
|
||||||
|
|
@ -264,14 +302,14 @@ watch(
|
||||||
[
|
[
|
||||||
() => props.phoneMode,
|
() => props.phoneMode,
|
||||||
() => templateAllowsAttachments.value,
|
() => templateAllowsAttachments.value,
|
||||||
() => form.contract_uuid,
|
() => form.contract_uuids,
|
||||||
() => form.decision_id,
|
() => form.decision_id,
|
||||||
() => availableContractDocs.value.length,
|
() => availableContractDocs.value.length,
|
||||||
],
|
],
|
||||||
() => {
|
() => {
|
||||||
if (!props.phoneMode) return;
|
if (!props.phoneMode) return;
|
||||||
if (!templateAllowsAttachments.value) return;
|
if (!templateAllowsAttachments.value) return;
|
||||||
if (!form.contract_uuid) return;
|
if (!form.contract_uuids || form.contract_uuids.length !== 1) return;
|
||||||
const docs = availableContractDocs.value;
|
const docs = availableContractDocs.value;
|
||||||
if (docs.length === 0) return;
|
if (docs.length === 0) return;
|
||||||
form.attach_documents = true;
|
form.attach_documents = true;
|
||||||
|
|
@ -324,6 +362,22 @@ watch(
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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">
|
<div class="space-y-2">
|
||||||
<Label for="activityNote">Opomba</Label>
|
<Label for="activityNote">Opomba</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
@ -369,7 +423,7 @@ watch(
|
||||||
{{ autoMailDisabledHint }}
|
{{ autoMailDisabledHint }}
|
||||||
</p>
|
</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">
|
<label class="inline-flex items-center gap-2">
|
||||||
<Switch v-model="form.attach_documents" />
|
<Switch v-model="form.attach_documents" />
|
||||||
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
|
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
|
||||||
|
|
@ -383,7 +437,7 @@ watch(
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<template v-for="c in pageContracts" :key="c.uuid || c.id">
|
<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">
|
<div class="font-medium text-sm text-gray-700 mb-1">
|
||||||
Pogodba {{ c.reference }}
|
Pogodba {{ c.reference }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,40 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||||
import SectionTitle from "@/Components/SectionTitle.vue";
|
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||||
import DataTableServer from "@/Components/DataTable/DataTableServer.vue";
|
|
||||||
import { Link, router } from "@inertiajs/vue3";
|
import { Link, router } from "@inertiajs/vue3";
|
||||||
import { ref, computed, watch } from "vue";
|
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({
|
const props = defineProps({
|
||||||
activities: { type: Object, required: true },
|
activities: { type: Object, required: true },
|
||||||
today: { type: String, required: true },
|
today: { type: String, required: true },
|
||||||
// Optional: full list of clients with unread items to populate filter dropdown
|
|
||||||
clients: { type: Array, default: () => [] },
|
clients: { type: Array, default: () => [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -21,6 +46,7 @@ function fmtDate(d) {
|
||||||
return String(d);
|
return String(d);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtEUR(value) {
|
function fmtEUR(value) {
|
||||||
if (value === null || value === undefined) return "—";
|
if (value === null || value === undefined) return "—";
|
||||||
const num = typeof value === "string" ? Number(value) : value;
|
const num = typeof value === "string" ? Number(value) : value;
|
||||||
|
|
@ -34,13 +60,12 @@ function fmtEUR(value) {
|
||||||
return formatted.replace("\u00A0", " ");
|
return formatted.replace("\u00A0", " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Client filter (like Segments/Show.vue) ---
|
// Client filter
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const initialClient = urlParams.get("client") || urlParams.get("client_id") || "";
|
const initialClient = urlParams.get("client") || urlParams.get("client_id") || "";
|
||||||
const selectedClient = ref(initialClient);
|
const selectedClient = ref(initialClient);
|
||||||
|
|
||||||
const clientOptions = computed(() => {
|
const clientOptions = computed(() => {
|
||||||
// Prefer server-provided clients list; fallback to deriving from rows
|
|
||||||
const list =
|
const list =
|
||||||
Array.isArray(props.clients) && props.clients.length
|
Array.isArray(props.clients) && props.clients.length
|
||||||
? props.clients
|
? props.clients
|
||||||
|
|
@ -72,43 +97,20 @@ watch(selectedClient, (val) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Row selection - connected to DataTableNew2's built-in selection
|
||||||
const selectedRows = ref([]);
|
const selectedRows = ref([]);
|
||||||
|
const dataTableRef = ref(null);
|
||||||
|
|
||||||
function toggleSelectAll() {
|
function handleSelectionChange(selectedKeys) {
|
||||||
if (selectedRows.value.length === (props.activities.data?.length || 0)) {
|
selectedRows.value = selectedKeys.map((val, i) => {
|
||||||
selectedRows.value = [];
|
const nu = toNumber(val);
|
||||||
} else {
|
return props.activities.data[val].id;
|
||||||
selectedRows.value = (props.activities.data || []).map((row) => row.id);
|
});
|
||||||
}
|
|
||||||
}
|
console.log(selectedRows.value);
|
||||||
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark as read actions
|
||||||
function markRead(id) {
|
function markRead(id) {
|
||||||
router.patch(
|
router.patch(
|
||||||
route("notifications.activity.read"),
|
route("notifications.activity.read"),
|
||||||
|
|
@ -130,143 +132,131 @@ function markReadBulk() {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
selectedRows.value = [];
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout title="Obvestila">
|
<AppLayout title="Obvestila">
|
||||||
<template #header></template>
|
<template #header></template>
|
||||||
<div class="py-12">
|
<div class="py-12">
|
||||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
<Card>
|
||||||
<div class="mx-auto max-w-4x1 py-3">
|
<CardHeader>
|
||||||
<div class="pb-3">
|
<div class="flex items-center justify-between">
|
||||||
<SectionTitle>
|
<div class="flex items-center gap-3">
|
||||||
<template #title>Neprikazana obvestila</template>
|
<div
|
||||||
<template #description>Do danes: {{ fmtDate(today) }}</template>
|
class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10"
|
||||||
</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
|
|
||||||
>
|
>
|
||||||
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<select
|
<Select v-model="selectedClient">
|
||||||
v-model="selectedClient"
|
<SelectTrigger id="client-filter">
|
||||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm"
|
<SelectValue placeholder="Vsi partnerji" />
|
||||||
>
|
</SelectTrigger>
|
||||||
<option value="">Vsi partnerji</option>
|
<SelectContent>
|
||||||
<option
|
<SelectItem
|
||||||
v-for="opt in clientOptions"
|
v-for="opt in clientOptions"
|
||||||
:key="opt.value || opt.label"
|
:key="opt.value || opt.label"
|
||||||
:value="opt.value"
|
:value="opt.value"
|
||||||
>
|
>
|
||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</option>
|
</SelectItem>
|
||||||
</select>
|
</SelectContent>
|
||||||
<button
|
</Select>
|
||||||
|
<Button
|
||||||
v-if="selectedClient"
|
v-if="selectedClient"
|
||||||
type="button"
|
variant="ghost"
|
||||||
class="text-sm text-gray-600 hover:text-gray-900"
|
size="icon"
|
||||||
@click="selectedClient = ''"
|
@click="selectedClient = ''"
|
||||||
|
title="Počisti filter"
|
||||||
>
|
>
|
||||||
Počisti
|
<X class="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTableServer
|
<!-- Data Table -->
|
||||||
:columns="[
|
<DataTable
|
||||||
{ key: 'select', label: '', sortable: false, width: '50px' },
|
ref="dataTableRef"
|
||||||
{ key: 'what', label: 'Zadeva', sortable: false },
|
:columns="columns"
|
||||||
{ key: 'partner', label: 'Partner', sortable: false },
|
:data="rows"
|
||||||
{
|
:meta="activities"
|
||||||
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,
|
|
||||||
}"
|
|
||||||
route-name="notifications.unread"
|
route-name="notifications.unread"
|
||||||
page-param-name="unread-page"
|
page-param-name="unread-page"
|
||||||
:only-props="['activities']"
|
: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>
|
<!-- Bulk Actions Toolbar -->
|
||||||
<div v-if="selectedRows.length" class="flex items-center gap-2">
|
<template #toolbar-filters>
|
||||||
<div class="text-sm text-gray-700">
|
<div v-if="selectedRows.length" class="flex items-center gap-3">
|
||||||
Izbrano: <span class="font-medium">{{ selectedRows.length }}</span>
|
<div class="text-sm text-muted-foreground">
|
||||||
|
Izbrano:
|
||||||
|
<span class="font-medium text-foreground">{{
|
||||||
|
selectedRows.length
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<Dropdown width="48" align="left">
|
<DropdownMenu>
|
||||||
<template #trigger>
|
<DropdownMenuTrigger as-child>
|
||||||
<button
|
<Button variant="outline" size="sm" class="gap-2">
|
||||||
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"
|
|
||||||
>
|
|
||||||
Akcije
|
Akcije
|
||||||
<svg
|
<ChevronDown class="h-4 w-4" />
|
||||||
class="ml-1 h-4 w-4"
|
</Button>
|
||||||
viewBox="0 0 20 20"
|
</DropdownMenuTrigger>
|
||||||
fill="currentColor"
|
<DropdownMenuContent align="start">
|
||||||
aria-hidden="true"
|
<DropdownMenuItem @click="markReadBulk">
|
||||||
>
|
<Check class="h-4 w-4" />
|
||||||
<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"
|
|
||||||
>
|
|
||||||
Označi kot prebrano
|
Označi kot prebrano
|
||||||
</button>
|
</DropdownMenuItem>
|
||||||
</template>
|
</DropdownMenuContent>
|
||||||
</Dropdown>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #header-select>
|
|
||||||
<input
|
<!-- What Column -->
|
||||||
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>
|
|
||||||
<template #cell-what="{ row }">
|
<template #cell-what="{ row }">
|
||||||
<div class="font-medium text-gray-800 truncate">
|
<div class="font-medium truncate">
|
||||||
<template v-if="row.contract?.uuid">
|
<template v-if="row.contract?.uuid">
|
||||||
Pogodba:
|
<span class="text-muted-foreground">Pogodba:</span>
|
||||||
<Link
|
<Link
|
||||||
v-if="row.contract?.client_case?.uuid"
|
v-if="row.contract?.client_case?.uuid"
|
||||||
:href="
|
:href="
|
||||||
|
|
@ -274,27 +264,31 @@ function markReadBulk() {
|
||||||
client_case: row.contract.client_case.uuid,
|
client_case: row.contract.client_case.uuid,
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
class="text-indigo-600 hover:underline"
|
class="ml-1 text-primary hover:underline"
|
||||||
>
|
>
|
||||||
{{ row.contract?.reference || "—" }}
|
{{ row.contract?.reference || "—" }}
|
||||||
</Link>
|
</Link>
|
||||||
<span v-else>{{ row.contract?.reference || "—" }}</span>
|
<span v-else class="ml-1">{{ row.contract?.reference || "—" }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
Primer:
|
<span class="text-muted-foreground">Primer:</span>
|
||||||
<Link
|
<Link
|
||||||
v-if="row.client_case?.uuid"
|
v-if="row.client_case?.uuid"
|
||||||
:href="
|
:href="
|
||||||
route('clientCase.show', { client_case: row.client_case.uuid })
|
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 || "—" }}
|
{{ row.client_case?.person?.full_name || "—" }}
|
||||||
</Link>
|
</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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Partner Column -->
|
||||||
<template #cell-partner="{ row }">
|
<template #cell-partner="{ row }">
|
||||||
<div class="truncate">
|
<div class="truncate">
|
||||||
{{
|
{{
|
||||||
|
|
@ -304,34 +298,51 @@ function markReadBulk() {
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Balance Column -->
|
||||||
<template #cell-balance="{ row }">
|
<template #cell-balance="{ row }">
|
||||||
<div class="text-right">
|
<div class="text-right font-medium">
|
||||||
<span v-if="row.contract">{{
|
<span v-if="row.contract">{{
|
||||||
fmtEUR(row.contract?.account?.balance_amount)
|
fmtEUR(row.contract?.account?.balance_amount)
|
||||||
}}</span>
|
}}</span>
|
||||||
<span v-else>—</span>
|
<span v-else class="text-muted-foreground">—</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Due Date Column -->
|
||||||
<template #cell-due="{ row }">
|
<template #cell-due="{ row }">
|
||||||
{{ fmtDate(row.due_date) }}
|
<div class="text-sm">
|
||||||
</template>
|
{{ fmtDate(row.due_date) }}
|
||||||
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DataTableServer>
|
|
||||||
</div>
|
<!-- Actions Column -->
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
|
||||||
|
|
@ -454,7 +454,7 @@ const clientSummary = computed(() => {
|
||||||
:key="a.id"
|
:key="a.id"
|
||||||
class="bg-gray-50/70 dark:bg-gray-800/50"
|
class="bg-gray-50/70 dark:bg-gray-800/50"
|
||||||
>
|
>
|
||||||
<CardHeader class="pb-3">
|
<CardHeader>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<CardTitle class="text-sm font-medium truncate">
|
<CardTitle class="text-sm font-medium truncate">
|
||||||
{{ activityActionLine(a) || "Aktivnost" }}
|
{{ activityActionLine(a) || "Aktivnost" }}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import ActionSection from '@/Components/ActionSection.vue';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||||
import DangerButton from '@/Components/DangerButton.vue';
|
import { Button } from '@/Components/ui/button';
|
||||||
import DialogModal from '@/Components/DialogModal.vue';
|
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 InputError from '@/Components/InputError.vue';
|
||||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
import { Trash2, AlertTriangle } from 'lucide-vue-next';
|
||||||
import TextInput from '@/Components/TextInput.vue';
|
|
||||||
|
|
||||||
const confirmingUserDeletion = ref(false);
|
const confirmingUserDeletion = ref(false);
|
||||||
const passwordInput = ref(null);
|
const passwordInput = ref(null);
|
||||||
|
|
@ -38,65 +38,68 @@ const closeModal = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ActionSection>
|
<Card>
|
||||||
<template #title>
|
<CardHeader>
|
||||||
Delete Account
|
<div class="flex items-center gap-2">
|
||||||
</template>
|
<Trash2 class="h-5 w-5 text-destructive" />
|
||||||
|
<CardTitle>Delete Account</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Permanently delete your account.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
<template #description>
|
<CardContent class="space-y-4">
|
||||||
Permanently delete your account.
|
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||||
</template>
|
<div class="flex gap-3">
|
||||||
|
<AlertTriangle class="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
<template #content>
|
<p class="text-sm text-foreground">
|
||||||
<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.
|
||||||
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>
|
||||||
|
|
||||||
<div class="mt-5">
|
<Button variant="destructive" @click="confirmUserDeletion">
|
||||||
<DangerButton @click="confirmUserDeletion">
|
<Trash2 class="h-4 w-4 mr-2" />
|
||||||
Delete Account
|
Delete Account
|
||||||
</DangerButton>
|
</Button>
|
||||||
</div>
|
</CardContent>
|
||||||
|
|
||||||
<!-- Delete Account Confirmation Modal -->
|
<!-- Delete Account Confirmation Dialog -->
|
||||||
<DialogModal :show="confirmingUserDeletion" @close="closeModal">
|
<AlertDialog :open="confirmingUserDeletion" @update:open="closeModal">
|
||||||
<template #title>
|
<AlertDialogContent>
|
||||||
Delete Account
|
<AlertDialogHeader>
|
||||||
</template>
|
<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>
|
<div class="py-4">
|
||||||
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.
|
<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">
|
<AlertDialogFooter>
|
||||||
<TextInput
|
<Button variant="outline" @click="closeModal">
|
||||||
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">
|
|
||||||
Cancel
|
Cancel
|
||||||
</SecondaryButton>
|
</Button>
|
||||||
|
<Button
|
||||||
<DangerButton
|
variant="destructive"
|
||||||
class="ms-3"
|
|
||||||
:class="{ 'opacity-25': form.processing }"
|
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
@click="deleteUser"
|
@click="deleteUser"
|
||||||
>
|
>
|
||||||
Delete Account
|
Delete Account
|
||||||
</DangerButton>
|
</Button>
|
||||||
</template>
|
</AlertDialogFooter>
|
||||||
</DialogModal>
|
</AlertDialogContent>
|
||||||
</template>
|
</AlertDialog>
|
||||||
</ActionSection>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,141 +1,159 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from "vue";
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from "@inertiajs/vue3";
|
||||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
import {
|
||||||
import ActionSection from '@/Components/ActionSection.vue';
|
Card,
|
||||||
import DialogModal from '@/Components/DialogModal.vue';
|
CardContent,
|
||||||
import InputError from '@/Components/InputError.vue';
|
CardDescription,
|
||||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
CardHeader,
|
||||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
CardTitle,
|
||||||
import TextInput from '@/Components/TextInput.vue';
|
} 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({
|
defineProps({
|
||||||
sessions: Array,
|
sessions: Array,
|
||||||
});
|
});
|
||||||
|
|
||||||
const confirmingLogout = ref(false);
|
const confirmingLogout = ref(false);
|
||||||
const passwordInput = ref(null);
|
const passwordInput = ref(null);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
password: '',
|
password: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const confirmLogout = () => {
|
const confirmLogout = () => {
|
||||||
confirmingLogout.value = true;
|
confirmingLogout.value = true;
|
||||||
|
|
||||||
setTimeout(() => passwordInput.value.focus(), 250);
|
setTimeout(() => passwordInput.value.focus(), 250);
|
||||||
};
|
};
|
||||||
|
|
||||||
const logoutOtherBrowserSessions = () => {
|
const logoutOtherBrowserSessions = () => {
|
||||||
form.delete(route('other-browser-sessions.destroy'), {
|
form.delete(route("other-browser-sessions.destroy"), {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => closeModal(),
|
onSuccess: () => closeModal(),
|
||||||
onError: () => passwordInput.value.focus(),
|
onError: () => passwordInput.value.focus(),
|
||||||
onFinish: () => form.reset(),
|
onFinish: () => form.reset(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
confirmingLogout.value = false;
|
confirmingLogout.value = false;
|
||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ActionSection>
|
<Card>
|
||||||
<template #title>
|
<CardHeader>
|
||||||
Browser Sessions
|
<div class="flex items-center gap-2">
|
||||||
</template>
|
<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>
|
<CardContent class="space-y-6">
|
||||||
Manage and log out your active sessions on other browsers and devices.
|
<p class="text-sm text-muted-foreground">
|
||||||
</template>
|
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>
|
<!-- Other Browser Sessions -->
|
||||||
<div class="max-w-xl text-sm text-gray-600">
|
<div v-if="sessions.length > 0" class="space-y-4">
|
||||||
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.
|
<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>
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground mt-1">
|
||||||
<!-- Other Browser Sessions -->
|
{{ session.ip_address }}
|
||||||
<div v-if="sessions.length > 0" class="mt-5 space-y-6">
|
<span
|
||||||
<div v-for="(session, i) in sessions" :key="i" class="flex items-center">
|
v-if="session.is_current_device"
|
||||||
<div>
|
class="inline-flex items-center ml-2 text-green-600 dark:text-green-400 font-semibold"
|
||||||
<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" />
|
This device
|
||||||
</svg>
|
</span>
|
||||||
|
<span v-else class="ml-1"> · Last active {{ session.last_active }} </span>
|
||||||
<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>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center mt-5">
|
<div class="flex items-center gap-3">
|
||||||
<PrimaryButton @click="confirmLogout">
|
<Button @click="confirmLogout">
|
||||||
Log Out Other Browser Sessions
|
<LogOut class="h-4 w-4 mr-2" />
|
||||||
</PrimaryButton>
|
Log Out Other Browser Sessions
|
||||||
|
</Button>
|
||||||
|
|
||||||
<ActionMessage :on="form.recentlySuccessful" class="ms-3">
|
<div
|
||||||
Done.
|
v-if="form.recentlySuccessful"
|
||||||
</ActionMessage>
|
class="flex items-center gap-1.5 text-sm text-muted-foreground"
|
||||||
</div>
|
>
|
||||||
|
<CheckCircle class="h-4 w-4 text-green-600" />
|
||||||
|
<span>Done.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
<!-- Log Out Other Devices Confirmation Modal -->
|
<!-- Log Out Other Devices Confirmation Dialog -->
|
||||||
<DialogModal :show="confirmingLogout" @close="closeModal">
|
<Dialog :open="confirmingLogout" @update:open="closeModal">
|
||||||
<template #title>
|
<DialogContent>
|
||||||
Log Out Other Browser Sessions
|
<DialogHeader>
|
||||||
</template>
|
<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>
|
<div class="py-4">
|
||||||
Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.
|
<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">
|
<DialogFooter>
|
||||||
<TextInput
|
<Button variant="outline" @click="closeModal"> Cancel </Button>
|
||||||
ref="passwordInput"
|
<Button :disabled="form.processing" @click="logoutOtherBrowserSessions">
|
||||||
v-model="form.password"
|
Log Out Other Browser Sessions
|
||||||
type="password"
|
</Button>
|
||||||
class="mt-1 block w-3/4"
|
</DialogFooter>
|
||||||
placeholder="Password"
|
</DialogContent>
|
||||||
autocomplete="current-password"
|
</Dialog>
|
||||||
@keyup.enter="logoutOtherBrowserSessions"
|
</Card>
|
||||||
/>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import { router, useForm, usePage } from '@inertiajs/vue3';
|
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 ConfirmsPassword from '@/Components/ConfirmsPassword.vue';
|
||||||
import DangerButton from '@/Components/DangerButton.vue';
|
|
||||||
import InputError from '@/Components/InputError.vue';
|
import InputError from '@/Components/InputError.vue';
|
||||||
import InputLabel from '@/Components/InputLabel.vue';
|
import { Shield, Key, Copy, RefreshCw, CheckCircle, AlertCircle } from 'lucide-vue-next';
|
||||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
|
||||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
|
||||||
import TextInput from '@/Components/TextInput.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
requiresConfirmation: Boolean,
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ActionSection>
|
<Card>
|
||||||
<template #title>
|
<CardHeader>
|
||||||
Two Factor Authentication
|
<div class="flex items-center gap-2">
|
||||||
</template>
|
<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>
|
<CardContent class="space-y-6">
|
||||||
Add additional security to your account using two factor authentication.
|
<!-- Status Header -->
|
||||||
</template>
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
<template #content>
|
<h3 v-if="twoFactorEnabled && ! confirming" class="text-lg font-semibold flex items-center gap-2">
|
||||||
<h3 v-if="twoFactorEnabled && ! confirming" class="text-lg font-medium text-gray-900">
|
<CheckCircle class="h-5 w-5 text-green-600" />
|
||||||
You have enabled two factor authentication.
|
Two factor authentication is enabled
|
||||||
</h3>
|
</h3>
|
||||||
|
<h3 v-else-if="twoFactorEnabled && confirming" class="text-lg font-semibold flex items-center gap-2">
|
||||||
<h3 v-else-if="twoFactorEnabled && confirming" class="text-lg font-medium text-gray-900">
|
<AlertCircle class="h-5 w-5 text-amber-600" />
|
||||||
Finish enabling two factor authentication.
|
Finish enabling two factor authentication
|
||||||
</h3>
|
</h3>
|
||||||
|
<h3 v-else class="text-lg font-semibold flex items-center gap-2">
|
||||||
<h3 v-else class="text-lg font-medium text-gray-900">
|
<Shield class="h-5 w-5 text-muted-foreground" />
|
||||||
You have not enabled two factor authentication.
|
Two factor authentication is disabled
|
||||||
</h3>
|
</h3>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
<div class="mt-3 max-w-xl text-sm text-gray-600">
|
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>
|
</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.
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="twoFactorEnabled">
|
<!-- QR Code & Setup -->
|
||||||
<div v-if="qrCode">
|
<div v-if="twoFactorEnabled" class="space-y-6">
|
||||||
<div class="mt-4 max-w-xl text-sm text-gray-600">
|
<div v-if="qrCode" class="space-y-4">
|
||||||
<p v-if="confirming" class="font-semibold">
|
<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.
|
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>
|
||||||
|
<p v-else class="text-sm text-muted-foreground mb-4">
|
||||||
<p v-else>
|
|
||||||
Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.
|
Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div class="mt-4 p-2 inline-block bg-white" v-html="qrCode" />
|
<!-- Confirmation Code Input -->
|
||||||
|
<div v-if="confirming" class="space-y-2">
|
||||||
<div v-if="setupKey" class="mt-4 max-w-xl text-sm text-gray-600">
|
<Label for="code">Confirmation Code</Label>
|
||||||
<p class="font-semibold">
|
<Input
|
||||||
Setup Key: <span v-html="setupKey"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="confirming" class="mt-4">
|
|
||||||
<InputLabel for="code" value="Code" />
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
id="code"
|
id="code"
|
||||||
v-model="confirmationForm.code"
|
v-model="confirmationForm.code"
|
||||||
type="text"
|
type="text"
|
||||||
name="code"
|
name="code"
|
||||||
class="block mt-1 w-1/2"
|
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
autofocus
|
autofocus
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
|
placeholder="Enter 6-digit code"
|
||||||
|
class="max-w-xs"
|
||||||
@keyup.enter="confirmTwoFactorAuthentication"
|
@keyup.enter="confirmTwoFactorAuthentication"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputError :message="confirmationForm.errors.code" class="mt-2" />
|
<InputError :message="confirmationForm.errors.code" class="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="recoveryCodes.length > 0 && ! confirming">
|
<!-- Recovery Codes -->
|
||||||
<div class="mt-4 max-w-xl text-sm text-gray-600">
|
<div v-if="recoveryCodes.length > 0 && ! confirming" class="space-y-4">
|
||||||
<p class="font-semibold">
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950">
|
||||||
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.
|
<div class="flex items-start gap-2">
|
||||||
</p>
|
<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>
|
||||||
|
|
||||||
<div class="grid gap-1 max-w-xl mt-4 px-4 py-4 font-mono text-sm bg-gray-100 rounded-lg">
|
<div class="rounded-lg border bg-muted p-4">
|
||||||
<div v-for="code in recoveryCodes" :key="code">
|
<div class="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||||
{{ code }}
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5">
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<!-- Enable -->
|
||||||
<div v-if="! twoFactorEnabled">
|
<div v-if="! twoFactorEnabled">
|
||||||
<ConfirmsPassword @confirmed="enableTwoFactorAuthentication">
|
<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
|
Enable
|
||||||
</PrimaryButton>
|
</Button>
|
||||||
</ConfirmsPassword>
|
</ConfirmsPassword>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<!-- Confirm -->
|
||||||
|
<template v-else>
|
||||||
<ConfirmsPassword @confirmed="confirmTwoFactorAuthentication">
|
<ConfirmsPassword @confirmed="confirmTwoFactorAuthentication">
|
||||||
<PrimaryButton
|
<Button
|
||||||
v-if="confirming"
|
v-if="confirming"
|
||||||
type="button"
|
type="button"
|
||||||
class="me-3"
|
|
||||||
:class="{ 'opacity-25': enabling }"
|
|
||||||
:disabled="enabling"
|
:disabled="enabling"
|
||||||
>
|
>
|
||||||
|
<CheckCircle class="h-4 w-4 mr-2" />
|
||||||
Confirm
|
Confirm
|
||||||
</PrimaryButton>
|
</Button>
|
||||||
</ConfirmsPassword>
|
</ConfirmsPassword>
|
||||||
|
|
||||||
|
<!-- Regenerate Recovery Codes -->
|
||||||
<ConfirmsPassword @confirmed="regenerateRecoveryCodes">
|
<ConfirmsPassword @confirmed="regenerateRecoveryCodes">
|
||||||
<SecondaryButton
|
<Button
|
||||||
v-if="recoveryCodes.length > 0 && ! confirming"
|
v-if="recoveryCodes.length > 0 && ! confirming"
|
||||||
class="me-3"
|
type="button"
|
||||||
|
variant="outline"
|
||||||
>
|
>
|
||||||
|
<RefreshCw class="h-4 w-4 mr-2" />
|
||||||
Regenerate Recovery Codes
|
Regenerate Recovery Codes
|
||||||
</SecondaryButton>
|
</Button>
|
||||||
</ConfirmsPassword>
|
</ConfirmsPassword>
|
||||||
|
|
||||||
|
<!-- Show Recovery Codes -->
|
||||||
<ConfirmsPassword @confirmed="showRecoveryCodes">
|
<ConfirmsPassword @confirmed="showRecoveryCodes">
|
||||||
<SecondaryButton
|
<Button
|
||||||
v-if="recoveryCodes.length === 0 && ! confirming"
|
v-if="recoveryCodes.length === 0 && ! confirming"
|
||||||
class="me-3"
|
type="button"
|
||||||
|
variant="outline"
|
||||||
>
|
>
|
||||||
|
<Key class="h-4 w-4 mr-2" />
|
||||||
Show Recovery Codes
|
Show Recovery Codes
|
||||||
</SecondaryButton>
|
</Button>
|
||||||
</ConfirmsPassword>
|
</ConfirmsPassword>
|
||||||
|
|
||||||
|
<!-- Cancel/Disable -->
|
||||||
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
||||||
<SecondaryButton
|
<Button
|
||||||
v-if="confirming"
|
v-if="confirming"
|
||||||
:class="{ 'opacity-25': disabling }"
|
type="button"
|
||||||
|
variant="outline"
|
||||||
:disabled="disabling"
|
:disabled="disabling"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</SecondaryButton>
|
</Button>
|
||||||
</ConfirmsPassword>
|
</ConfirmsPassword>
|
||||||
|
|
||||||
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
|
||||||
<DangerButton
|
<Button
|
||||||
v-if="! confirming"
|
v-if="! confirming"
|
||||||
:class="{ 'opacity-25': disabling }"
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
:disabled="disabling"
|
:disabled="disabling"
|
||||||
>
|
>
|
||||||
Disable
|
Disable
|
||||||
</DangerButton>
|
</Button>
|
||||||
</ConfirmsPassword>
|
</ConfirmsPassword>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</CardContent>
|
||||||
</ActionSection>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||||
import FormSection from '@/Components/FormSection.vue';
|
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 InputError from '@/Components/InputError.vue';
|
||||||
import InputLabel from '@/Components/InputLabel.vue';
|
import { CheckCircle, Lock } from 'lucide-vue-next';
|
||||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
|
||||||
import TextInput from '@/Components/TextInput.vue';
|
|
||||||
|
|
||||||
const passwordInput = ref(null);
|
const passwordInput = ref(null);
|
||||||
const currentPasswordInput = ref(null);
|
const currentPasswordInput = ref(null);
|
||||||
|
|
@ -38,63 +38,64 @@ const updatePassword = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<FormSection @submitted="updatePassword">
|
<Card>
|
||||||
<template #title>
|
<form @submit.prevent="updatePassword">
|
||||||
Update Password
|
<CardHeader>
|
||||||
</template>
|
<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>
|
<CardContent class="space-y-6">
|
||||||
Ensure your account is using a long, random password to stay secure.
|
<div class="space-y-2">
|
||||||
</template>
|
<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="space-y-2">
|
||||||
<div class="col-span-6 sm:col-span-4">
|
<Label for="password">New Password</Label>
|
||||||
<InputLabel for="current_password" value="Current Password" />
|
<Input
|
||||||
<TextInput
|
id="password"
|
||||||
id="current_password"
|
ref="passwordInput"
|
||||||
ref="currentPasswordInput"
|
v-model="form.password"
|
||||||
v-model="form.current_password"
|
type="password"
|
||||||
type="password"
|
autocomplete="new-password"
|
||||||
class="mt-1 block w-full"
|
/>
|
||||||
autocomplete="current-password"
|
<InputError :message="form.errors.password" class="mt-2" />
|
||||||
/>
|
</div>
|
||||||
<InputError :message="form.errors.current_password" class="mt-2" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-6 sm:col-span-4">
|
<div class="space-y-2">
|
||||||
<InputLabel for="password" value="New Password" />
|
<Label for="password_confirmation">Confirm Password</Label>
|
||||||
<TextInput
|
<Input
|
||||||
id="password"
|
id="password_confirmation"
|
||||||
ref="passwordInput"
|
v-model="form.password_confirmation"
|
||||||
v-model="form.password"
|
type="password"
|
||||||
type="password"
|
autocomplete="new-password"
|
||||||
class="mt-1 block w-full"
|
/>
|
||||||
autocomplete="new-password"
|
<InputError :message="form.errors.password_confirmation" class="mt-2" />
|
||||||
/>
|
</div>
|
||||||
<InputError :message="form.errors.password" class="mt-2" />
|
</CardContent>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-6 sm:col-span-4">
|
<CardFooter class="flex items-center justify-between">
|
||||||
<InputLabel for="password_confirmation" value="Confirm Password" />
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<TextInput
|
<CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" />
|
||||||
id="password_confirmation"
|
<span v-if="form.recentlySuccessful">Saved.</span>
|
||||||
v-model="form.password_confirmation"
|
</div>
|
||||||
type="password"
|
<Button type="submit" :disabled="form.processing">
|
||||||
class="mt-1 block w-full"
|
Save
|
||||||
autocomplete="new-password"
|
</Button>
|
||||||
/>
|
</CardFooter>
|
||||||
<InputError :message="form.errors.password_confirmation" class="mt-2" />
|
</form>
|
||||||
</div>
|
</Card>
|
||||||
</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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { Link, router, useForm } from '@inertiajs/vue3';
|
import { Link, router, useForm } from '@inertiajs/vue3';
|
||||||
import ActionMessage from '@/Components/ActionMessage.vue';
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||||
import FormSection from '@/Components/FormSection.vue';
|
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 InputError from '@/Components/InputError.vue';
|
||||||
import InputLabel from '@/Components/InputLabel.vue';
|
import { User, Mail, Camera, Trash2, CheckCircle, AlertCircle } from 'lucide-vue-next';
|
||||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
|
||||||
import SecondaryButton from '@/Components/SecondaryButton.vue';
|
|
||||||
import TextInput from '@/Components/TextInput.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
user: Object,
|
user: Object,
|
||||||
|
|
@ -76,115 +76,138 @@ const clearPhotoFileInput = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<FormSection @submitted="updateProfileInformation">
|
<Card>
|
||||||
<template #title>
|
<form @submit.prevent="updateProfileInformation">
|
||||||
Profile Information
|
<CardHeader>
|
||||||
</template>
|
<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>
|
<CardContent class="space-y-6">
|
||||||
Update your account's profile information and email address.
|
<!-- Profile Photo -->
|
||||||
</template>
|
<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>
|
<Label for="photo">Photo</Label>
|
||||||
<!-- 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"
|
|
||||||
>
|
|
||||||
|
|
||||||
<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 class="flex gap-2">
|
||||||
<div v-show="! photoPreview" class="mt-2">
|
<Button
|
||||||
<img :src="user.profile_photo_url" :alt="user.name" class="rounded-full h-20 w-20 object-cover">
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- New Profile Photo Preview -->
|
<!-- Name -->
|
||||||
<div v-show="photoPreview" class="mt-2">
|
<div class="space-y-2">
|
||||||
<span
|
<Label for="name">Name</Label>
|
||||||
class="block rounded-full w-20 h-20 bg-cover bg-no-repeat bg-center"
|
<Input
|
||||||
:style="'background-image: url(\'' + photoPreview + '\');'"
|
id="name"
|
||||||
|
v-model="form.name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autocomplete="name"
|
||||||
/>
|
/>
|
||||||
|
<InputError :message="form.errors.name" class="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SecondaryButton class="mt-2 me-2" type="button" @click.prevent="selectNewPhoto">
|
<!-- Email -->
|
||||||
Select A New Photo
|
<div class="space-y-2">
|
||||||
</SecondaryButton>
|
<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
|
<!-- Email Verification -->
|
||||||
v-if="user.profile_photo_path"
|
<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">
|
||||||
type="button"
|
<div class="flex items-start gap-2">
|
||||||
class="mt-2"
|
<AlertCircle class="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5" />
|
||||||
@click.prevent="deletePhoto"
|
<div class="flex-1 text-sm">
|
||||||
>
|
<p class="text-amber-800 dark:text-amber-200">
|
||||||
Remove Photo
|
Your email address is unverified.
|
||||||
</SecondaryButton>
|
<Link
|
||||||
|
:href="route('verification.send')"
|
||||||
<InputError :message="form.errors.photo" class="mt-2" />
|
method="post"
|
||||||
</div>
|
as="button"
|
||||||
|
class="underline text-amber-900 hover:text-amber-700 dark:text-amber-100 dark:hover:text-amber-300 font-medium"
|
||||||
<!-- Name -->
|
@click.prevent="sendEmailVerification"
|
||||||
<div class="col-span-6 sm:col-span-4">
|
>
|
||||||
<InputLabel for="name" value="Name" />
|
Click here to re-send the verification email.
|
||||||
<TextInput
|
</Link>
|
||||||
id="name"
|
</p>
|
||||||
v-model="form.name"
|
<div v-show="verificationLinkSent" class="mt-2 flex items-center gap-1.5 text-green-700 dark:text-green-400">
|
||||||
type="text"
|
<CheckCircle class="h-4 w-4" />
|
||||||
class="mt-1 block w-full"
|
<span>A new verification link has been sent to your email address.</span>
|
||||||
required
|
</div>
|
||||||
autocomplete="name"
|
</div>
|
||||||
/>
|
</div>
|
||||||
<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.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
<CardFooter class="flex items-center justify-between">
|
||||||
<ActionMessage :on="form.recentlySuccessful" class="me-3">
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
Saved.
|
<CheckCircle v-if="form.recentlySuccessful" class="h-4 w-4 text-green-600" />
|
||||||
</ActionMessage>
|
<span v-if="form.recentlySuccessful">Saved.</span>
|
||||||
|
</div>
|
||||||
<PrimaryButton :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
|
<Button type="submit" :disabled="form.processing">
|
||||||
Save
|
Save
|
||||||
</PrimaryButton>
|
</Button>
|
||||||
</template>
|
</CardFooter>
|
||||||
</FormSection>
|
</form>
|
||||||
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||||
import DeleteUserForm from '@/Pages/Profile/Partials/DeleteUserForm.vue';
|
import DeleteUserForm from '@/Pages/Profile/Partials/DeleteUserForm.vue';
|
||||||
import LogoutOtherBrowserSessionsForm from '@/Pages/Profile/Partials/LogoutOtherBrowserSessionsForm.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 TwoFactorAuthenticationForm from '@/Pages/Profile/Partials/TwoFactorAuthenticationForm.vue';
|
||||||
import UpdatePasswordForm from '@/Pages/Profile/Partials/UpdatePasswordForm.vue';
|
import UpdatePasswordForm from '@/Pages/Profile/Partials/UpdatePasswordForm.vue';
|
||||||
import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfileInformationForm.vue';
|
import UpdateProfileInformationForm from '@/Pages/Profile/Partials/UpdateProfileInformationForm.vue';
|
||||||
|
|
@ -26,13 +26,13 @@ defineProps({
|
||||||
<div v-if="$page.props.jetstream.canUpdateProfileInformation">
|
<div v-if="$page.props.jetstream.canUpdateProfileInformation">
|
||||||
<UpdateProfileInformationForm :user="$page.props.auth.user" />
|
<UpdateProfileInformationForm :user="$page.props.auth.user" />
|
||||||
|
|
||||||
<SectionBorder />
|
<Separator class="my-10" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="$page.props.jetstream.canUpdatePassword">
|
<div v-if="$page.props.jetstream.canUpdatePassword">
|
||||||
<UpdatePasswordForm class="mt-10 sm:mt-0" />
|
<UpdatePasswordForm class="mt-10 sm:mt-0" />
|
||||||
|
|
||||||
<SectionBorder />
|
<Separator class="my-10" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="$page.props.jetstream.canManageTwoFactorAuthentication">
|
<div v-if="$page.props.jetstream.canManageTwoFactorAuthentication">
|
||||||
|
|
@ -41,7 +41,7 @@ defineProps({
|
||||||
class="mt-10 sm:mt-0"
|
class="mt-10 sm:mt-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionBorder />
|
<Separator class="my-10" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LogoutOtherBrowserSessionsForm :sessions="sessions" class="mt-10 sm:mt-0" />
|
<LogoutOtherBrowserSessionsForm :sessions="sessions" class="mt-10 sm:mt-0" />
|
||||||
|
|
|
||||||
|
|
@ -516,6 +516,9 @@ function extractFilenameFromHeaders(headers) {
|
||||||
</Link>
|
</Link>
|
||||||
<span v-else>{{ row.client_case?.person?.full_name || "-" }}</span>
|
<span v-else>{{ row.client_case?.person?.full_name || "-" }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template #cell-address="{ row }">
|
||||||
|
{{ row.client_case?.person?.address?.address }}
|
||||||
|
</template>
|
||||||
<template #cell-client="{ row }">
|
<template #cell-client="{ row }">
|
||||||
{{ row.client?.person?.full_name || "-" }}
|
{{ row.client?.person?.full_name || "-" }}
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@
|
||||||
|
|
||||||
// Packages (batch jobs)
|
// Packages (batch jobs)
|
||||||
Route::get('packages', [\App\Http\Controllers\Admin\PackageController::class, 'index'])->name('packages.index');
|
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::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', [\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');
|
Route::post('packages/{package}/dispatch', [\App\Http\Controllers\Admin\PackageController::class, 'dispatch'])->name('packages.dispatch');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user