Skip to content

Commit b6d3eb2

Browse files
committed
feat: edit projects
1 parent c321903 commit b6d3eb2

File tree

7 files changed

+1589
-1700
lines changed

7 files changed

+1589
-1700
lines changed

frontend/app/api/generated.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1422,6 +1422,21 @@ export type AdminHomePageQueryVariables = Exact<{ [key: string]: never; }>;
14221422

14231423
export type AdminHomePageQuery = { __typename?: 'Query', me: { __typename?: 'User', id: string, name: string }, projects: { __typename?: 'ProjectConnection', edges: Array<{ __typename?: 'ProjectEdge', node: { __typename?: 'Project', id: string, name: string, description: string, endDate: any, startDate: any, branding: { __typename?: 'Branding', logo?: string | null, rounding: number, colors: { __typename?: 'Colors', primary: string, secondary: string, tertiary: string } } } }> } };
14241424

1425+
export type AdminProjectEditPageQueryVariables = Exact<{
1426+
projectId: Scalars['ID']['input'];
1427+
}>;
1428+
1429+
1430+
export type AdminProjectEditPageQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, name: string, description: string, startDate: any, endDate: any, archivedAt?: boolean | null, branding: { __typename?: 'Branding', logo?: string | null, rounding: number, colors: { __typename?: 'Colors', primary: string } } } };
1431+
1432+
export type UpdateProjectMutationVariables = Exact<{
1433+
id: Scalars['ID']['input'];
1434+
input: UpdateProjectInput;
1435+
}>;
1436+
1437+
1438+
export type UpdateProjectMutation = { __typename?: 'Mutation', updateProject: { __typename?: 'Project', id: string } };
1439+
14251440
export type AdminProjectPageQueryVariables = Exact<{
14261441
projectId: Scalars['ID']['input'];
14271442
}>;
@@ -1600,6 +1615,40 @@ export const AdminHomePageDocument = gql`
16001615
export function useAdminHomePageQuery(options?: Omit<Urql.UseQueryArgs<never, AdminHomePageQueryVariables | undefined>, 'query'>) {
16011616
return Urql.useQuery<AdminHomePageQuery, AdminHomePageQueryVariables | undefined>({ query: AdminHomePageDocument, variables: undefined, ...options });
16021617
};
1618+
export const AdminProjectEditPageDocument = gql`
1619+
query AdminProjectEditPage($projectId: ID!) {
1620+
project(id: $projectId) {
1621+
id
1622+
name
1623+
description
1624+
startDate
1625+
endDate
1626+
archivedAt
1627+
branding {
1628+
logo
1629+
rounding
1630+
colors {
1631+
primary
1632+
}
1633+
}
1634+
}
1635+
}
1636+
`;
1637+
1638+
export function useAdminProjectEditPageQuery(options?: Omit<Urql.UseQueryArgs<never, AdminProjectEditPageQueryVariables | undefined>, 'query'>) {
1639+
return Urql.useQuery<AdminProjectEditPageQuery, AdminProjectEditPageQueryVariables | undefined>({ query: AdminProjectEditPageDocument, variables: undefined, ...options });
1640+
};
1641+
export const UpdateProjectDocument = gql`
1642+
mutation UpdateProject($id: ID!, $input: UpdateProjectInput!) {
1643+
updateProject(id: $id, input: $input) {
1644+
id
1645+
}
1646+
}
1647+
`;
1648+
1649+
export function useUpdateProjectMutation() {
1650+
return Urql.useMutation<UpdateProjectMutation, UpdateProjectMutationVariables>(UpdateProjectDocument);
1651+
};
16031652
export const AdminProjectPageDocument = gql`
16041653
query AdminProjectPage($projectId: ID!) {
16051654
project(id: $projectId) {

frontend/app/app.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,10 @@ export default defineAppConfig({
44
primary: 'emerald',
55
neutral: 'zinc',
66
},
7+
formField: {
8+
slots: {
9+
labelWrapper: 'justify-start gap-2',
10+
},
11+
},
712
},
813
})
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<script setup lang="ts">
2+
import { parseDate, type DateValue } from '@internationalized/date'
3+
4+
const start = defineModel<string>('start')
5+
const end = defineModel<string>('end')
6+
7+
const isOpen = ref(false)
8+
9+
function toDateString(dateStr: string | undefined): string | undefined {
10+
if (!dateStr) return undefined
11+
// Handle both ISO timestamps and date-only strings
12+
return dateStr.split('T')[0]
13+
}
14+
15+
function toCalendarDate(dateStr: string | undefined): DateValue | undefined {
16+
if (!dateStr) return undefined
17+
const dateOnly = toDateString(dateStr)
18+
if (!dateOnly) return undefined
19+
try {
20+
return parseDate(dateOnly)
21+
} catch {
22+
return undefined
23+
}
24+
}
25+
26+
function toISOWithTimezone(dateValue: DateValue): string {
27+
// Create a date at 01:00:00 local time
28+
const date = new Date(
29+
dateValue.year,
30+
dateValue.month - 1,
31+
dateValue.day,
32+
1,
33+
0,
34+
0,
35+
)
36+
37+
// Format as ISO string with timezone
38+
const year = date.getFullYear()
39+
const month = String(date.getMonth() + 1).padStart(2, '0')
40+
const day = String(date.getDate()).padStart(2, '0')
41+
const hours = String(date.getHours()).padStart(2, '0')
42+
const minutes = String(date.getMinutes()).padStart(2, '0')
43+
const seconds = String(date.getSeconds()).padStart(2, '0')
44+
45+
// Get timezone offset
46+
const offset = -date.getTimezoneOffset()
47+
const offsetHours = Math.floor(Math.abs(offset) / 60)
48+
const offsetMinutes = Math.abs(offset) % 60
49+
const offsetSign = offset >= 0 ? '+' : '-'
50+
const timezoneStr = `${offsetSign}${String(offsetHours).padStart(2, '0')}:${String(offsetMinutes).padStart(2, '0')}`
51+
52+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${timezoneStr}`
53+
}
54+
55+
const range = computed<{ start: DateValue; end: DateValue } | undefined>({
56+
get: () => {
57+
const startDate = toCalendarDate(start.value)
58+
const endDate = toCalendarDate(end.value)
59+
60+
// Only return a range if both start and end are present
61+
if (startDate && endDate) {
62+
return { start: startDate, end: endDate }
63+
}
64+
65+
// Return undefined if either is missing
66+
return undefined
67+
},
68+
set: (value) => {
69+
if (value?.start) {
70+
start.value = toISOWithTimezone(value.start)
71+
}
72+
if (value?.end) {
73+
end.value = toISOWithTimezone(value.end)
74+
}
75+
},
76+
})
77+
78+
function formatDate(dateStr: string | undefined) {
79+
if (!dateStr) return ''
80+
const dateOnly = toDateString(dateStr)
81+
if (!dateOnly) return ''
82+
const date = new Date(dateOnly + 'T00:00:00')
83+
return date.toLocaleDateString('en-US', {
84+
month: 'short',
85+
day: 'numeric',
86+
year: 'numeric',
87+
})
88+
}
89+
90+
const displayValue = computed(() => {
91+
const startFormatted = formatDate(start.value)
92+
const endFormatted = formatDate(end.value)
93+
94+
if (startFormatted && endFormatted) {
95+
return `${startFormatted} - ${endFormatted}`
96+
}
97+
if (startFormatted) {
98+
return startFormatted
99+
}
100+
if (endFormatted) {
101+
return endFormatted
102+
}
103+
return ''
104+
})
105+
</script>
106+
107+
<template>
108+
<UFormField label="Project duration">
109+
<UPopover v-model:open="isOpen" :ui="{ content: 'p-1' }">
110+
<UInput
111+
:model-value="displayValue"
112+
placeholder="Select date range"
113+
readonly
114+
icon="lucide:calendar"
115+
/>
116+
<template #content>
117+
<UCalendar v-model="range" range />
118+
</template>
119+
</UPopover>
120+
</UFormField>
121+
</template>
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
<script setup lang="ts">
2+
import type { FormSubmitEvent } from '@nuxt/ui'
3+
import z from 'zod'
4+
5+
definePageMeta({
6+
layout: 'admin',
7+
middleware: 'admin',
8+
})
9+
10+
gql(`
11+
query AdminProjectEditPage($projectId: ID!) {
12+
project(id: $projectId) {
13+
id
14+
name
15+
description
16+
startDate
17+
endDate
18+
archivedAt
19+
branding {
20+
logo
21+
rounding
22+
colors {
23+
primary
24+
}
25+
}
26+
}
27+
}
28+
29+
mutation UpdateProject($id: ID!, $input: UpdateProjectInput!) {
30+
updateProject(id: $id, input: $input) {
31+
id
32+
}
33+
}
34+
`)
35+
36+
const route = useRoute('admin-projects-projectId-edit')
37+
38+
const { isAuthReady } = useAuthReady()
39+
const { data } = useAdminProjectEditPageQuery({
40+
variables: {
41+
projectId: route.params.projectId,
42+
},
43+
pause: computed(() => !isAuthReady.value),
44+
})
45+
46+
const schema = z.object({
47+
name: z.string().nonempty({ error: 'Name is required' }),
48+
description: z.string().optional(),
49+
startDate: z.string().nonempty({ error: 'Start date is required' }),
50+
endDate: z.string().nonempty({ error: 'End date is required' }),
51+
branding: z.object({
52+
logo: z.string().optional(),
53+
colors: z.object({
54+
primary: z.string(),
55+
secondary: z.string(),
56+
tertiary: z.string(),
57+
}),
58+
rounding: z.number(),
59+
}),
60+
})
61+
type Schema = z.infer<typeof schema>
62+
const state = reactive<Schema>({
63+
name: '',
64+
description: undefined,
65+
startDate: '',
66+
endDate: '',
67+
branding: {
68+
logo: undefined,
69+
rounding: 0,
70+
colors: {
71+
primary: '#000000',
72+
secondary: '#000000',
73+
tertiary: '#000000',
74+
},
75+
},
76+
})
77+
78+
watch(
79+
() => data.value,
80+
(d) => {
81+
if (d) {
82+
state.name = d.project.name
83+
state.description = d.project.description
84+
state.startDate = d.project.startDate
85+
state.endDate = d.project.endDate
86+
state.branding.logo = d.project.branding.logo ?? undefined
87+
state.branding.rounding = d.project.branding.rounding
88+
state.branding.colors.primary = d.project.branding.colors.primary
89+
}
90+
},
91+
{ once: true },
92+
)
93+
94+
const { executeMutation } = useUpdateProjectMutation()
95+
const toast = useToast()
96+
97+
async function updateProject(event: FormSubmitEvent<Schema>) {
98+
if (!event.data) {
99+
return
100+
}
101+
102+
executeMutation({ id: route.params.projectId, input: event.data }).then(
103+
(response) => {
104+
if (response.error) {
105+
toast.add({
106+
title: response.error.name,
107+
description: response.error.message,
108+
color: 'error',
109+
})
110+
return
111+
}
112+
if (!response.data) {
113+
return
114+
}
115+
navigateTo({
116+
name: 'admin-projects-projectId',
117+
params: { projectId: response.data.updateProject.id },
118+
})
119+
},
120+
)
121+
}
122+
</script>
123+
124+
<template>
125+
<div>
126+
<div class="border-default border-b py-2">
127+
<UContainer>
128+
<UBreadcrumb
129+
:items="[
130+
{ label: 'Projects', to: { name: 'admin-projects' } },
131+
{
132+
label: state.name,
133+
to: {
134+
name: 'admin-projects-projectId',
135+
params: { projectId: route.params.projectId },
136+
},
137+
},
138+
{
139+
label: 'Edit',
140+
to: {
141+
name: 'admin-projects-projectId-edit',
142+
params: { projectId: route.params.projectId },
143+
},
144+
},
145+
]"
146+
/>
147+
</UContainer>
148+
</div>
149+
<UContainer class="py-12">
150+
<UForm
151+
:state
152+
:schema="schema"
153+
class="flex max-w-md flex-col gap-6"
154+
@submit.prevent="updateProject"
155+
>
156+
<UFormField name="name" label="Name">
157+
<UInput v-model="state.name" size="xl" required class="w-full" />
158+
</UFormField>
159+
<UFormField
160+
name="description"
161+
label="Description"
162+
hint="(optional)"
163+
help="This is only for admins to have better context"
164+
>
165+
<UTextarea v-model="state.description" class="w-full" autoresize />
166+
</UFormField>
167+
<DateRangeField
168+
v-model:start="state.startDate"
169+
v-model:end="state.endDate"
170+
/>
171+
<UFormField label="Accent color">
172+
<ColorPickerInput v-model="state.branding.colors.primary" />
173+
</UFormField>
174+
<UButton type="submit" size="lg" block>Save changes</UButton>
175+
</UForm>
176+
<pre>{{ state }}</pre>
177+
</UContainer>
178+
</div>
179+
</template>

0 commit comments

Comments
 (0)