Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7a76c9e9b | |||
| 7047f64a6b | |||
| cd8cea45c1 | |||
| 1f31a3a33f | |||
| 254f8bc411 | |||
| 239cd6398e |
@@ -12,9 +12,11 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
|||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration)
|
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument)
|
||||||
src/ApiResource/ # Ressources API Platform (si découplées des entités)
|
src/ApiResource/ # Ressources API Platform (si découplées des entités)
|
||||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, Gitea*Provider, Gitea*Processor)
|
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor)
|
||||||
|
src/Service/ # Services métier (NotificationService)
|
||||||
|
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController)
|
||||||
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
|
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
|
||||||
src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP)
|
src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP)
|
||||||
src/Command/ # Commandes console (GenerateApiTokenCommand)
|
src/Command/ # Commandes console (GenerateApiTokenCommand)
|
||||||
@@ -26,12 +28,12 @@ migrations/ # Migrations Doctrine
|
|||||||
docs/plans/ # Plans d'implémentation
|
docs/plans/ # Plans d'implémentation
|
||||||
docs/superpowers/ # Plans et specs superpowers
|
docs/superpowers/ # Plans et specs superpowers
|
||||||
frontend/ # App Nuxt 4
|
frontend/ # App Nuxt 4
|
||||||
frontend/pages/ # Pages (index, login, my-tasks, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin)
|
frontend/pages/ # Pages (index, login, my-tasks, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket)
|
||||||
frontend/layouts/ # Layouts (pas "layout")
|
frontend/layouts/ # Layouts (default, portal)
|
||||||
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/)
|
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/)
|
||||||
frontend/composables/# Composables (useApi, useAppVersion)
|
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers)
|
||||||
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
||||||
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries)
|
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents)
|
||||||
frontend/services/dto/ # Types TypeScript
|
frontend/services/dto/ # Types TypeScript
|
||||||
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||||
```
|
```
|
||||||
@@ -73,6 +75,11 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- Routes API préfixées `/api` (via `config/routes/api_platform.yaml`)
|
- Routes API préfixées `/api` (via `config/routes/api_platform.yaml`)
|
||||||
- Le login (`/login_check`) est hors prefix `/api`, nginx réécrit `REQUEST_URI` vers `/login_check`
|
- Le login (`/login_check`) est hors prefix `/api`, nginx réécrit `REQUEST_URI` vers `/login_check`
|
||||||
- PHP CS Fixer : règles Symfony + PSR-12 + strict types
|
- PHP CS Fixer : règles Symfony + PSR-12 + strict types
|
||||||
|
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT` — hiérarchie dans `security.yaml`
|
||||||
|
- `User::getRoles()` n'ajoute PAS `ROLE_USER` si l'user a `ROLE_CLIENT` (isolation)
|
||||||
|
- PostgreSQL : `LIKE` sur colonne JSON ne marche pas → utiliser `roles::text LIKE` via native SQL
|
||||||
|
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}`
|
||||||
|
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux propriétés de l'entité cible
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
@@ -82,6 +89,9 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- Middleware global `auth.global.ts` protège les routes
|
- Middleware global `auth.global.ts` protège les routes
|
||||||
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
|
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
|
||||||
- 4 espaces d'indentation
|
- 4 espaces d'indentation
|
||||||
|
- MalioSelect : options `{ label: string, value: number | null }` uniquement — pas de string values, utiliser `<select>` natif pour les enums string
|
||||||
|
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
|
||||||
|
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
|
||||||
|
|
||||||
### MCP Server
|
### MCP Server
|
||||||
|
|
||||||
@@ -111,4 +121,6 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
## Fixtures
|
## Fixtures
|
||||||
|
|
||||||
- User admin : `admin` / `admin` (ROLE_ADMIN)
|
- User admin : `admin` / `admin` (ROLE_ADMIN)
|
||||||
|
- Users internes : `alice` / `alice`, `bob` / `bob`, `charlie` / `charlie` (ROLE_USER)
|
||||||
|
- Users client : `client-liot` / `client` (ROLE_CLIENT, client LIOT → SIRH), `client-acme` / `client` (ROLE_CLIENT, client ACME → CRM)
|
||||||
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
||||||
|
|||||||
@@ -239,9 +239,12 @@ const canEdit = computed(() => {
|
|||||||
if (status === 'done' || status === 'rejected') return false
|
if (status === 'done' || status === 'rejected') return false
|
||||||
const userId = auth.user?.id
|
const userId = auth.user?.id
|
||||||
if (!userId) return false
|
if (!userId) return false
|
||||||
const submittedByIri = props.ticket.submittedBy
|
const sub = props.ticket.submittedBy
|
||||||
if (!submittedByIri) return false
|
if (!sub) return false
|
||||||
return submittedByIri === `/api/users/${userId}`
|
// submittedBy can be an IRI string or an embedded object
|
||||||
|
if (typeof sub === 'string') return sub === `/api/users/${userId}`
|
||||||
|
if (typeof sub === 'object' && 'id' in sub) return (sub as any).id === userId
|
||||||
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
function startEdit() {
|
function startEdit() {
|
||||||
|
|||||||
@@ -1,41 +1,57 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="date-filter">
|
<div class="date-filter">
|
||||||
<VueDatePicker
|
<VueDatePicker
|
||||||
|
ref="datepicker"
|
||||||
v-model="internalValue"
|
v-model="internalValue"
|
||||||
range
|
:week-picker="mode === 'week'"
|
||||||
:enable-time-picker="false"
|
:enable-time-picker="false"
|
||||||
:locale="frLocale"
|
:locale="frLocale"
|
||||||
:format="formatDate"
|
:format="formatDisplay"
|
||||||
auto-apply
|
auto-apply
|
||||||
:multi-calendars="false"
|
:multi-calendars="false"
|
||||||
position="left"
|
position="left"
|
||||||
|
teleport
|
||||||
@update:model-value="onUpdate"
|
@update:model-value="onUpdate"
|
||||||
>
|
>
|
||||||
<template #dp-input="{ value, onInput, onEnter, onTab, onClear, openMenu }">
|
<template #trigger>
|
||||||
<div class="relative">
|
<div class="flex items-center gap-1">
|
||||||
<input
|
<div class="flex shrink-0 overflow-hidden rounded-md border border-neutral-300">
|
||||||
:value="value"
|
<button
|
||||||
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
|
class="px-2 py-[7px] text-xs font-medium transition"
|
||||||
:placeholder="placeholder || t('common.dateFilter')"
|
:class="mode === 'day' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
|
||||||
readonly
|
@click.stop="switchMode('day')"
|
||||||
@click="openMenu"
|
>
|
||||||
@input="onInput"
|
{{ t('common.day') }}
|
||||||
@keydown.enter="onEnter"
|
</button>
|
||||||
@keydown.tab="onTab"
|
<button
|
||||||
/>
|
class="px-2 py-[7px] text-xs font-medium transition"
|
||||||
<button
|
:class="mode === 'week' ? 'bg-primary-500 text-white' : 'text-neutral-500 hover:bg-neutral-100'"
|
||||||
v-if="value"
|
@click.stop="switchMode('week')"
|
||||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
|
>
|
||||||
@click.stop="onClear"
|
{{ t('common.weekShort') }}
|
||||||
>
|
</button>
|
||||||
<Icon name="mdi:close-circle" size="16" />
|
</div>
|
||||||
</button>
|
<div class="relative cursor-pointer">
|
||||||
<Icon
|
<input
|
||||||
v-else
|
:value="displayValue"
|
||||||
name="mdi:calendar"
|
class="w-full cursor-pointer rounded-md border border-neutral-300 bg-white px-3 py-[7px] pr-8 text-sm text-neutral-700 outline-none transition placeholder:text-neutral-400 focus:border-primary-500"
|
||||||
size="16"
|
:placeholder="t('common.dateFilter')"
|
||||||
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400"
|
readonly
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
v-if="internalValue"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
|
||||||
|
@click.stop="onClear"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:close-circle" size="16" />
|
||||||
|
</button>
|
||||||
|
<Icon
|
||||||
|
v-else
|
||||||
|
name="mdi:calendar"
|
||||||
|
size="16"
|
||||||
|
class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -75,48 +91,87 @@ const emit = defineEmits<{
|
|||||||
'update:modelValue': [value: Date | [Date, Date] | null]
|
'update:modelValue': [value: Date | [Date, Date] | null]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const internalValue = ref<Date[] | null>(null)
|
const datepicker = ref<InstanceType<typeof VueDatePicker> | null>(null)
|
||||||
|
const mode = ref<'day' | 'week'>('week')
|
||||||
|
const internalValue = ref<Date | Date[] | null>(null)
|
||||||
|
|
||||||
function formatDate(dates: Date[]): string {
|
const displayValue = computed(() => {
|
||||||
if (!dates || dates.length === 0) return ''
|
if (!internalValue.value) return ''
|
||||||
if (dates.length === 1) return formatSingleDate(dates[0])
|
if (internalValue.value instanceof Date) {
|
||||||
if (isSameDay(dates[0], dates[1])) return formatSingleDate(dates[0])
|
return formatFullDate(internalValue.value)
|
||||||
return `${formatSingleDate(dates[0])} - ${formatSingleDate(dates[1])}`
|
}
|
||||||
|
if (Array.isArray(internalValue.value) && internalValue.value.length >= 2) {
|
||||||
|
const [start, end] = internalValue.value
|
||||||
|
if (!start || !end) return ''
|
||||||
|
return `${formatShortDate(start)} - ${formatShortDate(end)}`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDisplay(dates: Date | Date[]): string {
|
||||||
|
if (!dates) return ''
|
||||||
|
if (dates instanceof Date) return formatFullDate(dates)
|
||||||
|
if (!Array.isArray(dates)) return ''
|
||||||
|
const valid = dates.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()))
|
||||||
|
if (valid.length === 0) return ''
|
||||||
|
if (valid.length === 1) return formatFullDate(valid[0])
|
||||||
|
return `${formatShortDate(valid[0])} - ${formatShortDate(valid[1])}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSingleDate(d: Date): string {
|
function formatFullDate(d: Date): string {
|
||||||
|
if (!d || !(d instanceof Date) || isNaN(d.getTime())) return ''
|
||||||
const day = String(d.getDate()).padStart(2, '0')
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
const year = d.getFullYear()
|
const year = d.getFullYear()
|
||||||
return `${day}/${month}/${year}`
|
return `${day}/${month}/${year}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameDay(a: Date, b: Date): boolean {
|
function formatShortDate(d: Date): string {
|
||||||
return a.getFullYear() === b.getFullYear()
|
if (!d || !(d instanceof Date) || isNaN(d.getTime())) return ''
|
||||||
&& a.getMonth() === b.getMonth()
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
&& a.getDate() === b.getDate()
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
return `${day}/${month}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUpdate(value: Date[] | null) {
|
function switchMode(newMode: 'day' | 'week') {
|
||||||
if (!value || value.length === 0) {
|
if (mode.value === newMode) return
|
||||||
|
mode.value = newMode
|
||||||
|
internalValue.value = null
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUpdate(value: Date | Date[] | null) {
|
||||||
|
if (!value) {
|
||||||
emit('update:modelValue', null)
|
emit('update:modelValue', null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (value.length === 2 && isSameDay(value[0], value[1])) {
|
|
||||||
emit('update:modelValue', value[0])
|
if (mode.value === 'week' && Array.isArray(value)) {
|
||||||
} else if (value.length === 2) {
|
const valid = value.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()))
|
||||||
emit('update:modelValue', [value[0], value[1]])
|
if (valid.length >= 2) {
|
||||||
|
emit('update:modelValue', [valid[0], valid[1]])
|
||||||
|
}
|
||||||
|
} else if (mode.value === 'day' && value instanceof Date) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onClear() {
|
||||||
|
internalValue.value = null
|
||||||
|
datepicker.value?.closeMenu()
|
||||||
|
emit('update:modelValue', null)
|
||||||
|
}
|
||||||
|
|
||||||
function selectToday() {
|
function selectToday() {
|
||||||
|
mode.value = 'day'
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
internalValue.value = [today, today]
|
internalValue.value = today
|
||||||
emit('update:modelValue', today)
|
emit('update:modelValue', today)
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectThisWeek() {
|
function selectThisWeek() {
|
||||||
|
mode.value = 'week'
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const day = now.getDay()
|
const day = now.getDay()
|
||||||
const monday = new Date(now)
|
const monday = new Date(now)
|
||||||
@@ -135,7 +190,7 @@ watch(() => props.modelValue, (val) => {
|
|||||||
} else if (Array.isArray(val)) {
|
} else if (Array.isArray(val)) {
|
||||||
internalValue.value = [...val]
|
internalValue.value = [...val]
|
||||||
} else {
|
} else {
|
||||||
internalValue.value = [val, val]
|
internalValue.value = val
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ const clientOptions = computed(() => [
|
|||||||
const filteredProjects = computed(() => {
|
const filteredProjects = computed(() => {
|
||||||
if (form.clientId === null) return []
|
if (form.clientId === null) return []
|
||||||
return allProjects.value.filter(
|
return allProjects.value.filter(
|
||||||
(p) => p.client !== null && p.client.id === form.clientId,
|
(p) => p.client && typeof p.client === 'object' && 'id' in p.client && p.client.id === form.clientId,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,9 @@
|
|||||||
"dateFilter": "Date",
|
"dateFilter": "Date",
|
||||||
"today": "Aujourd'hui",
|
"today": "Aujourd'hui",
|
||||||
"thisWeek": "Cette semaine",
|
"thisWeek": "Cette semaine",
|
||||||
"clear": "Effacer"
|
"clear": "Effacer",
|
||||||
|
"day": "Jour",
|
||||||
|
"weekShort": "Sem."
|
||||||
},
|
},
|
||||||
"gitea": {
|
"gitea": {
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@@ -68,13 +68,12 @@ async function loadData() {
|
|||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
if (auth.user?.roles?.includes('ROLE_ADMIN')) {
|
if (auth.user?.roles?.includes('ROLE_ADMIN')) {
|
||||||
// Admin sees all projects
|
projects.value = await projectService.getAll({ archived: false })
|
||||||
const allProjects = await projectService.getAll({ archived: false })
|
|
||||||
projects.value = allProjects
|
|
||||||
} else {
|
} else {
|
||||||
// Client sees allowed projects
|
// allowedProjects are embedded objects from /api/me (with me:read group)
|
||||||
projects.value = auth.user?.allowedProjects ?? []
|
projects.value = (auth.user?.allowedProjects ?? []) as Project[]
|
||||||
}
|
}
|
||||||
|
|
||||||
tickets.value = await clientTicketService.getAll()
|
tickets.value = await clientTicketService.getAll()
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 -mb-24 min-h-0 flex-1">
|
<div class="relative z-0 mt-4 -mb-24 min-h-0 flex-1">
|
||||||
<TimeEntryList
|
<TimeEntryList
|
||||||
v-if="viewMode === 'list'"
|
v-if="viewMode === 'list'"
|
||||||
:entries="filteredEntries"
|
:entries="filteredEntries"
|
||||||
@@ -192,28 +192,6 @@ const filteredEntries = computed(() => {
|
|||||||
if (selectedTagId.value) {
|
if (selectedTagId.value) {
|
||||||
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
|
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
|
||||||
}
|
}
|
||||||
if (selectedDateFilter.value) {
|
|
||||||
if (Array.isArray(selectedDateFilter.value)) {
|
|
||||||
const [start, end] = selectedDateFilter.value
|
|
||||||
const startDay = new Date(start)
|
|
||||||
startDay.setHours(0, 0, 0, 0)
|
|
||||||
const endDay = new Date(end)
|
|
||||||
endDay.setHours(23, 59, 59, 999)
|
|
||||||
result = result.filter((e) => {
|
|
||||||
const entryDate = new Date(e.startedAt)
|
|
||||||
return entryDate >= startDay && entryDate <= endDay
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const day = new Date(selectedDateFilter.value)
|
|
||||||
day.setHours(0, 0, 0, 0)
|
|
||||||
const nextDay = new Date(day)
|
|
||||||
nextDay.setDate(nextDay.getDate() + 1)
|
|
||||||
result = result.filter((e) => {
|
|
||||||
const entryDate = new Date(e.startedAt)
|
|
||||||
return entryDate >= day && entryDate < nextDay
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -367,4 +345,16 @@ watch(viewMode, () => {
|
|||||||
watch(selectedUserId, () => {
|
watch(selectedUserId, () => {
|
||||||
loadEntries()
|
loadEntries()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(selectedDateFilter, (val) => {
|
||||||
|
if (!val) return
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
startDate.value = getMonday(val[0])
|
||||||
|
viewMode.value = 'week'
|
||||||
|
} else {
|
||||||
|
startDate.value = val
|
||||||
|
viewMode.value = 'day'
|
||||||
|
}
|
||||||
|
loadEntries()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
operations: [
|
operations: [
|
||||||
new GetCollection(security: "is_granted('ROLE_USER')"),
|
new GetCollection(security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')"),
|
||||||
new Get(security: "is_granted('ROLE_USER')"),
|
new Get(security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')"),
|
||||||
new Post(
|
new Post(
|
||||||
security: "is_granted('ROLE_ADMIN')",
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
denormalizationContext: ['groups' => ['project:write', 'project:create']],
|
denormalizationContext: ['groups' => ['project:write', 'project:create']],
|
||||||
@@ -41,7 +41,7 @@ class Project
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['project:read', 'time_entry:read', 'task:read'])]
|
#[Groups(['project:read', 'time_entry:read', 'task:read', 'me:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 10, unique: true)]
|
#[ORM\Column(length: 10, unique: true)]
|
||||||
@@ -51,7 +51,7 @@ class Project
|
|||||||
private ?string $code = null;
|
private ?string $code = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read'])]
|
#[Groups(['project:read', 'project:write', 'time_entry:read', 'task:read', 'me:read'])]
|
||||||
private ?string $name = null;
|
private ?string $name = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'text', nullable: true)]
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
|||||||
Reference in New Issue
Block a user