mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
added importer to organization settings
This commit is contained in:
@@ -17,16 +17,25 @@ class ImportController extends Controller
|
||||
* Import data into the organization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId importData
|
||||
*/
|
||||
public function import(Organization $organization, ImportRequest $request, ImportService $importService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'import');
|
||||
|
||||
try {
|
||||
$importData = base64_decode($request->input('data'), true);
|
||||
if ($importData === false) {
|
||||
return new JsonResponse([
|
||||
'message' => 'Invalid base64 encoded data',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$report = $importService->import(
|
||||
$organization,
|
||||
$request->input('type'),
|
||||
$request->input('data')
|
||||
$importData
|
||||
);
|
||||
|
||||
return new JsonResponse([
|
||||
|
||||
@@ -12,9 +12,9 @@ test('test that organization name can be updated', async ({ page }) => {
|
||||
await page.getByLabel('Team Name').fill('NEW ORG NAME');
|
||||
await page.getByLabel('Team Name').press('Enter');
|
||||
await page.getByLabel('Team Name').press('Meta+r');
|
||||
await expect(page.locator('[data-testid="organization_switcher"]:visible')).toContainText(
|
||||
'NEW ORG NAME'
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-testid="organization_switcher"]:visible')
|
||||
).toContainText('NEW ORG NAME');
|
||||
});
|
||||
|
||||
test('test that new manager can be invited', async ({ page }) => {
|
||||
|
||||
@@ -10,12 +10,37 @@ const ClientResource = z
|
||||
})
|
||||
.passthrough();
|
||||
const ClientCollection = z.array(ClientResource);
|
||||
const v1_import_import_Body = z
|
||||
const importData_Body = z
|
||||
.object({ type: z.string(), data: z.string() })
|
||||
.passthrough();
|
||||
const MemberResource = z
|
||||
const InvitationResource = z
|
||||
.object({ id: z.string(), user_id: z.string(), name: z.string() })
|
||||
.passthrough();
|
||||
const Role = z.enum(['owner', 'admin', 'manager', 'employee', 'placeholder']);
|
||||
const invite_Body = z
|
||||
.object({ email: z.string().email(), role: Role })
|
||||
.passthrough();
|
||||
const MemberPivotResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
user_id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
role: z.string(),
|
||||
is_placeholder: z.boolean(),
|
||||
billable_rate: z.union([z.number(), z.null()]),
|
||||
})
|
||||
.passthrough();
|
||||
const updateMember_Body = z
|
||||
.object({
|
||||
billable_rate: z.union([z.number(), z.null()]).optional(),
|
||||
role: Role,
|
||||
})
|
||||
.passthrough();
|
||||
const MemberResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
user_id: z.string(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
role: z.string(),
|
||||
@@ -23,7 +48,6 @@ const MemberResource = z
|
||||
billable_rate: z.union([z.number(), z.null()]),
|
||||
})
|
||||
.passthrough();
|
||||
const MemberCollection = z.array(MemberResource);
|
||||
const OrganizationResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -137,9 +161,13 @@ const updateTimeEntry_Body = z
|
||||
export const schemas = {
|
||||
ClientResource,
|
||||
ClientCollection,
|
||||
v1_import_import_Body,
|
||||
importData_Body,
|
||||
InvitationResource,
|
||||
Role,
|
||||
invite_Body,
|
||||
MemberPivotResource,
|
||||
updateMember_Body,
|
||||
MemberResource,
|
||||
MemberCollection,
|
||||
OrganizationResource,
|
||||
v1_organizations_update_Body,
|
||||
ProjectResource,
|
||||
@@ -378,13 +406,13 @@ const endpoints = makeApi([
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/import',
|
||||
alias: 'v1.import.import',
|
||||
alias: 'importData',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: v1_import_import_Body,
|
||||
schema: importData_Body,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
@@ -447,8 +475,8 @@ const endpoints = makeApi([
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/members',
|
||||
alias: 'getMembers',
|
||||
path: '/v1/organizations/:organization/invitations',
|
||||
alias: 'getInvitations',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
@@ -457,7 +485,39 @@ const endpoints = makeApi([
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: MemberCollection }).passthrough(),
|
||||
response: z
|
||||
.object({
|
||||
data: z.array(InvitationResource),
|
||||
links: z
|
||||
.object({
|
||||
first: z.union([z.string(), z.null()]),
|
||||
last: z.union([z.string(), z.null()]),
|
||||
prev: z.union([z.string(), z.null()]),
|
||||
next: z.union([z.string(), z.null()]),
|
||||
})
|
||||
.passthrough(),
|
||||
meta: z
|
||||
.object({
|
||||
current_page: z.number().int(),
|
||||
from: z.union([z.number(), z.null()]),
|
||||
last_page: z.number().int(),
|
||||
links: z.array(
|
||||
z
|
||||
.object({
|
||||
url: z.union([z.string(), z.null()]),
|
||||
label: z.string(),
|
||||
active: z.boolean(),
|
||||
})
|
||||
.passthrough()
|
||||
),
|
||||
path: z.union([z.string(), z.null()]),
|
||||
per_page: z.number().int(),
|
||||
to: z.union([z.number(), z.null()]),
|
||||
total: z.number().int(),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
@@ -483,7 +543,198 @@ const endpoints = makeApi([
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/members/:user/invite-placeholder',
|
||||
path: '/v1/organizations/:organization/invitations',
|
||||
alias: 'invite',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: invite_Body,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/organizations/:organization/members',
|
||||
alias: 'getMembers',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z
|
||||
.object({
|
||||
data: z.array(MemberPivotResource),
|
||||
links: z
|
||||
.object({
|
||||
first: z.union([z.string(), z.null()]),
|
||||
last: z.union([z.string(), z.null()]),
|
||||
prev: z.union([z.string(), z.null()]),
|
||||
next: z.union([z.string(), z.null()]),
|
||||
})
|
||||
.passthrough(),
|
||||
meta: z
|
||||
.object({
|
||||
current_page: z.number().int(),
|
||||
from: z.union([z.number(), z.null()]),
|
||||
last_page: z.number().int(),
|
||||
links: z.array(
|
||||
z
|
||||
.object({
|
||||
url: z.union([z.string(), z.null()]),
|
||||
label: z.string(),
|
||||
active: z.boolean(),
|
||||
})
|
||||
.passthrough()
|
||||
),
|
||||
path: z.union([z.string(), z.null()]),
|
||||
per_page: z.number().int(),
|
||||
to: z.union([z.number(), z.null()]),
|
||||
total: z.number().int(),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'put',
|
||||
path: '/v1/organizations/:organization/members/:membership',
|
||||
alias: 'updateMember',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: updateMember_Body,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'membership',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.object({ data: MemberResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
description: `Validation error`,
|
||||
schema: z
|
||||
.object({
|
||||
message: z.string(),
|
||||
errors: z.record(z.array(z.string())),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'delete',
|
||||
path: '/v1/organizations/:organization/members/:membership',
|
||||
alias: 'removeMember',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'body',
|
||||
type: 'Body',
|
||||
schema: z.object({}).partial().passthrough(),
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'membership',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
],
|
||||
response: z.null(),
|
||||
errors: [
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/members/:membership/invite-placeholder',
|
||||
alias: 'invitePlaceholder',
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
@@ -498,7 +749,7 @@ const endpoints = makeApi([
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
name: 'membership',
|
||||
type: 'Path',
|
||||
schema: z.string().uuid(),
|
||||
},
|
||||
@@ -909,6 +1160,17 @@ const endpoints = makeApi([
|
||||
],
|
||||
response: z.object({ data: ProjectMemberResource }).passthrough(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({
|
||||
error: z.boolean(),
|
||||
key: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
|
||||
@@ -41,23 +41,23 @@ const switchToTeam = (team: Organization) => {
|
||||
<div
|
||||
data-testid="organization_switcher"
|
||||
class="flex hover:bg-white/10 cursor-pointer transition px-2 py-1 rounded-lg w-full items-center justify-between font-medium">
|
||||
<div class="flex flex-1 space-x-3 items-center w-4/5">
|
||||
<div
|
||||
class="flex flex-1 space-x-2 items-center w-[calc(100%-30px)]">
|
||||
<div
|
||||
class="rounded sm:rounded-lg bg-blue-900 font-semibold text-xs sm:text-base flex-shrink-0 text-white w-5 sm:w-7 h-5 sm:h-7 flex items-center justify-center">
|
||||
class="rounded sm:rounded-lg bg-blue-900 font-semibold text-xs sm:text-sm flex-shrink-0 text-white w-5 sm:w-6 h-5 sm:h-6 flex items-center justify-center">
|
||||
{{
|
||||
page.props.auth.user.current_team.name
|
||||
.slice(0, 1)
|
||||
.toUpperCase()
|
||||
}}
|
||||
</div>
|
||||
<span
|
||||
class="text-sm sm:text-lg flex-1 truncate font-semibold">
|
||||
<span class="text-sm flex-1 truncate font-semibold">
|
||||
{{ page.props.auth.user.current_team.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-1/5">
|
||||
<div class="w-[30px]">
|
||||
<button
|
||||
class="p-1 transition hover:bg-white/10 rounded-full flex items-center w-9 h-9">
|
||||
class="p-1 transition hover:bg-white/10 rounded-full flex items-center w-8 h-8">
|
||||
<ChevronDownIcon
|
||||
class="w-5 sm:w-full mt-[1px]"></ChevronDownIcon>
|
||||
</button>
|
||||
|
||||
@@ -55,10 +55,10 @@ onMounted(async () => {
|
||||
<div>
|
||||
<div
|
||||
class="border-b border-default-background-separator pb-2 flex justify-between">
|
||||
<OrganizationSwitcher></OrganizationSwitcher>
|
||||
<OrganizationSwitcher class="w-full"></OrganizationSwitcher>
|
||||
<XMarkIcon
|
||||
@click="showSidebarMenu = false"
|
||||
class="w-8"></XMarkIcon>
|
||||
class="w-8 sm:hidden"></XMarkIcon>
|
||||
</div>
|
||||
<div class="border-b border-default-background-separator">
|
||||
<CurrentSidebarTimer></CurrentSidebarTimer>
|
||||
|
||||
142
resources/js/Pages/Teams/Partials/ImportData.vue
Normal file
142
resources/js/Pages/Teams/Partials/ImportData.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import FormSection from '@/Components/FormSection.vue';
|
||||
import PrimaryButton from '@/Components/PrimaryButton.vue';
|
||||
import type { Organization } from '@/types/models';
|
||||
import { ref } from 'vue';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
import { api } from '../../../../../openapi.json.client';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import { PhotoIcon } from '@heroicons/vue/24/solid';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
|
||||
defineProps<{
|
||||
team: Organization;
|
||||
}>();
|
||||
|
||||
type ImportType =
|
||||
| 'toggl_time_entries'
|
||||
| 'toggl_data_importer'
|
||||
| 'clockify_time_entries'
|
||||
| 'clockify_projects';
|
||||
|
||||
const importTypeOptions: { value: ImportType; label: string }[] = [
|
||||
{ value: 'toggl_time_entries', label: 'Toggl Time Entries' },
|
||||
{ value: 'toggl_data_importer', label: 'Toggl Data Importer' },
|
||||
{ value: 'clockify_time_entries', label: 'Clockify Time Entries' },
|
||||
{ value: 'clockify_projects', label: 'Clockify Projects' },
|
||||
];
|
||||
|
||||
const { addNotification } = useNotificationsStore();
|
||||
|
||||
async function importData() {
|
||||
const files = importFile.value?.files ?? [];
|
||||
if (importType.value === null) {
|
||||
addNotification('error', 'Please select the import type');
|
||||
return;
|
||||
}
|
||||
if (files.length !== 1) {
|
||||
addNotification(
|
||||
'error',
|
||||
'Please select the CSV or ZIP file that you want to import'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const base64String = await toBase64(files[0]);
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId !== null) {
|
||||
await api.importData(
|
||||
{
|
||||
type: importType.value,
|
||||
data: base64String.replace('data:text/csv;base64,', ''),
|
||||
},
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const importFile = ref<HTMLInputElement | null>();
|
||||
|
||||
function toBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
if (reader.result instanceof ArrayBuffer) {
|
||||
const decoder = new TextDecoder();
|
||||
const str = decoder.decode(reader.result);
|
||||
return reject(str);
|
||||
} else if (reader.result) {
|
||||
resolve(reader.result);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
||||
const importType = ref<ImportType | null>(null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormSection @submitted="importData">
|
||||
<template #title> Import Data</template>
|
||||
|
||||
<template #description>
|
||||
Import existing data from Toggl or Clockify.
|
||||
</template>
|
||||
|
||||
<template #form>
|
||||
<!-- Organization Owner Information -->
|
||||
<div class="col-span-6">
|
||||
<div class="col-span-6 sm:col-span-4">
|
||||
<InputLabel for="currency" value="Import Type" />
|
||||
<select
|
||||
name="currency"
|
||||
id="currency"
|
||||
v-model="importType"
|
||||
class="mt-1 block w-full border-input-border bg-input-background text-white focus:border-input-border-active rounded-md shadow-sm">
|
||||
<option value="" disabled>Select a currency</option>
|
||||
<option
|
||||
v-for="importTypeOption in importTypeOptions"
|
||||
:key="importTypeOption.value"
|
||||
:value="importTypeOption.value">
|
||||
{{ importTypeOption.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-2 flex justify-center rounded-lg border border-dashed border-white/30 px-6 py-10">
|
||||
<div class="text-center">
|
||||
<PhotoIcon
|
||||
class="mx-auto h-12 w-12 text-gray-500"
|
||||
aria-hidden="true" />
|
||||
|
||||
<div class="mt-4 flex text-sm leading-6 text-muted">
|
||||
<label
|
||||
for="file-upload"
|
||||
class="relative cursor-pointer rounded-md bg-gray-900 font-semibold text-white focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 focus-within:ring-offset-gray-900 hover:text-indigo-500">
|
||||
<span>Upload a Toggl/Clockify Export</span>
|
||||
<input
|
||||
ref="importFile"
|
||||
id="file-upload"
|
||||
name="file-upload"
|
||||
type="file"
|
||||
class="sr-only" />
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted">
|
||||
CSV and ZIP are supported
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<PrimaryButton @click="importData">Import Data</PrimaryButton>
|
||||
</template>
|
||||
</FormSection>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ import TeamMemberManager from '@/Pages/Teams/Partials/TeamMemberManager.vue';
|
||||
import UpdateTeamNameForm from '@/Pages/Teams/Partials/UpdateTeamNameForm.vue';
|
||||
import type { Organization } from '@/types/models';
|
||||
import type { Permissions, Role } from '@/types/jetstream';
|
||||
import ImportData from '@/Pages/Teams/Partials/ImportData.vue';
|
||||
|
||||
defineProps<{
|
||||
team: Organization;
|
||||
@@ -38,6 +39,10 @@ defineProps<{
|
||||
|
||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||
</template>
|
||||
|
||||
<SectionBorder />
|
||||
|
||||
<ImportData :team="team"></ImportData>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
@@ -24,7 +24,7 @@ class ImportEndpointTest extends ApiEndpointTestAbstract
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.import.import', ['organization' => $data->organization->id]), [
|
||||
'type' => 'toggl_time_entries',
|
||||
'data' => 'some data',
|
||||
'data' => base64_encode('some data'),
|
||||
'options' => [],
|
||||
]);
|
||||
|
||||
@@ -50,7 +50,7 @@ class ImportEndpointTest extends ApiEndpointTestAbstract
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->id]), [
|
||||
'type' => 'toggl_time_entries',
|
||||
'data' => 'some data',
|
||||
'data' => base64_encode('some data'),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
@@ -86,7 +86,7 @@ class ImportEndpointTest extends ApiEndpointTestAbstract
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->id]), [
|
||||
'type' => 'toggl_time_entries',
|
||||
'data' => 'some data',
|
||||
'data' => base64_encode('some data'),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
|
||||
Reference in New Issue
Block a user