add comment to company

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
Ronni Skansing
2025-10-14 18:19:52 +02:00
parent 912488eb92
commit a6374bd976
7 changed files with 178 additions and 49 deletions
+3
View File
@@ -26,6 +26,7 @@ var CompanyColumnsMap = map[string]string{
"created_at": repository.TableColumn(database.COMPANY_TABLE, "created_at"),
"updated_at": repository.TableColumn(database.COMPANY_TABLE, "updated_at"),
"name": repository.TableColumn(database.COMPANY_TABLE, "name"),
"comment": repository.TableColumn(database.COMPANY_TABLE, "comment"),
}
// Company is a Company controller
@@ -93,6 +94,7 @@ func (c *Company) ExportByCompanyID(g *gin.Context) {
"Created at",
"Updated at",
"Name",
"Comment",
}
err = writer.Write(headers)
if ok := c.handleErrors(g, err); !ok {
@@ -102,6 +104,7 @@ func (c *Company) ExportByCompanyID(g *gin.Context) {
utils.CSVFromDate(company.CreatedAt),
utils.CSVFromDate(company.UpdatedAt),
utils.CSVRemoveFormulaStart(utils.NullableToString(company.Name)),
utils.CSVRemoveFormulaStart(utils.NullableToString(company.Comment)),
}
err = writer.Write(row)
if ok := c.handleErrors(g, err); !ok {
+1
View File
@@ -15,6 +15,7 @@ type Company struct {
CreatedAt *time.Time `gorm:"not null;index;"`
UpdatedAt *time.Time `gorm:"not null;index"`
Name string `gorm:"not null;unique;index"`
Comment *string `gorm:"type:text"`
// backref: many-to-one
Users []*User //`gorm:"foreignKey:CompanyID;"`
+11 -4
View File
@@ -11,10 +11,11 @@ import (
// Company is a company
type Company struct {
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
Name nullable.Nullable[vo.String64] `json:"name"`
ID nullable.Nullable[uuid.UUID] `json:"id"`
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
Name nullable.Nullable[vo.String64] `json:"name"`
Comment nullable.Nullable[vo.OptionalString1MB] `json:"comment"`
}
// Validate checks if the Company configuration with a valid state
@@ -36,5 +37,11 @@ func (c *Company) ToDBMap() map[string]any {
m["name"] = name.String()
}
}
if c.Comment.IsSpecified() {
m["comment"] = nil
if comment, err := c.Comment.Get(); err == nil {
m["comment"] = comment.String()
}
}
return m
}
+6
View File
@@ -17,6 +17,7 @@ var companyAllowedColumns = assignTableToColumns(database.COMPANY_TABLE, []strin
"created_at",
"updated_at",
"name",
"comment",
})
// Company is a Company repository
@@ -147,10 +148,15 @@ func (r *Company) DeleteByID(
func ToCompany(row *database.Company) *model.Company {
id := nullable.NewNullableWithValue(row.ID)
name := nullable.NewNullableWithValue(*vo.NewString64Must(row.Name))
var comment nullable.Nullable[vo.OptionalString1MB]
if row.Comment != nil {
comment = nullable.NewNullableWithValue(*vo.NewUnsafeOptionalString1MB(*row.Comment))
}
return &model.Company{
ID: id,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
Name: name,
Comment: comment,
}
}
+27 -19
View File
@@ -197,31 +197,39 @@ func (s *Company) UpdateByID(
s.Logger.Debugw("failed to get company name", "error", err)
return err
}
// check if company name is unique, let a TOCTOU error
// happen as a generic error, this is easier than checking
// all database types specific unique contraint errors
_, err = s.CompanyRepository.GetByName(
ctx,
name.String(),
)
// we expect not to find a company with this name
// so any error is an actual error
if err != nil {
// something went wrong
if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Logger.Debugw("failed to get existing company name", "error", current.Name)
return err
// check if company name is unique, excluding the current company
// only check if the name has actually changed
currentName := current.Name.MustGet().String()
if name.String() != currentName {
// check if company name is unique, let a TOCTOU error
// happen as a generic error, this is easier than checking
// all database types specific unique contraint errors
existingCompany, err := s.CompanyRepository.GetByName(
ctx,
name.String(),
)
// we expect not to find a company with this name
if err != nil {
// something went wrong
if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Logger.Debugw("failed to get existing company name", "error", err)
return err
}
}
// if there is no error, then the company name is already taken
// but we need to make sure it's not the same company we're updating
if err == nil && existingCompany.ID.MustGet() != *id {
s.Logger.Debugw("company name is already taken", "error", name.String())
return validate.WrapErrorWithField(errors.New("not unique"), "name")
}
}
// if there is no error, then the company name is already taken
if err == nil {
s.Logger.Debugw("company name is already taken", "error", name.String())
return validate.WrapErrorWithField(errors.New("not unique"), "name")
}
// update changed fields
if v, err := company.Name.Get(); err == nil {
current.Name.Set(v)
}
if v, err := company.Comment.Get(); err == nil {
current.Comment.Set(v)
}
// validate
if err := company.Validate(); err != nil {
s.Logger.Errorw("failed to validate company", "error", err)
+8 -4
View File
@@ -1078,11 +1078,13 @@ export class API {
* Create a new company.
*
* @param {string} name
* @param {string} comment
* @returns {Promise<ApiResponse>}
*/
create: async (name) => {
create: async (name, comment) => {
return await postJSON(this.getPath(`/company`), {
name: name
name: name,
comment: comment
});
},
@@ -1091,11 +1093,13 @@ export class API {
*
* @param {string} id
* @param {string} name
* @param {string} comment
* @returns {Promise<ApiResponse>}
*/
update: async (id, name) => {
update: async (id, name, comment) => {
return await postJSON(this.getPath(`/company/${id}`), {
name: name
name: name,
comment: comment
});
},
+122 -22
View File
@@ -2,7 +2,6 @@
import { api } from '$lib/api/apiProxy.js';
import { onMount } from 'svelte';
import Headline from '$lib/components/Headline.svelte';
import TextField from '$lib/components/TextField.svelte';
import TableRow from '$lib/components/table/TableRow.svelte';
import TableCell from '$lib/components/table/TableCell.svelte';
import TableUpdateButton from '$lib/components/table/TableUpdateButton.svelte';
@@ -13,10 +12,7 @@
import TableCellEmpty from '$lib/components/table/TableCellEmpty.svelte';
import { newTableURLParams } from '$lib/service/tableURLParams.js';
import Modal from '$lib/components/Modal.svelte';
import FormGrid from '$lib/components/FormGrid.svelte';
import BigButton from '$lib/components/BigButton.svelte';
import FormColumns from '$lib/components/FormColumns.svelte';
import FormColumn from '$lib/components/FormColumn.svelte';
import FormFooter from '$lib/components/FormFooter.svelte';
import Table from '$lib/components/table/Table.svelte';
import HeadTitle from '$lib/components/HeadTitle.svelte';
@@ -28,7 +24,8 @@
// bindings
let form = null;
const formValues = {
name: null
name: null,
comment: null
};
// data
let modalError = '';
@@ -46,6 +43,9 @@
name: null
};
let isViewCommentModalVisible = false;
let viewCommentCompany = null;
$: {
modalText = modalMode === 'create' ? 'New company' : 'Update company';
}
@@ -120,7 +120,7 @@
const create = async () => {
modalError = '';
try {
const res = await api.company.create(formValues.name);
const res = await api.company.create(formValues.name, formValues.comment);
if (!res.success) {
modalError = res.error;
return;
@@ -137,7 +137,7 @@
const update = async () => {
modalError = '';
try {
const res = await api.company.update(formValues.id, formValues.name);
const res = await api.company.update(formValues.id, formValues.name, formValues.comment);
if (!res.success) {
modalError = res.error;
return;
@@ -179,12 +179,20 @@
const openCreateModal = () => {
modalMode = 'create';
modalError = '';
// reset form values for create mode
formValues.id = null;
formValues.name = null;
formValues.comment = null;
isModalVisible = true;
};
const closeModal = () => {
modalError = '';
isModalVisible = false;
// reset form values
formValues.id = null;
formValues.name = null;
formValues.comment = null;
form.reset();
};
@@ -198,6 +206,7 @@
const company = await getCompany(id);
formValues.id = company.id;
formValues.name = company.name;
formValues.comment = company.comment || null;
isModalVisible = true;
} catch (e) {
addToast('Failed to get company', 'Error');
@@ -210,9 +219,23 @@
const closeUpdateModal = () => {
isModalVisible = false;
modalError = '';
// reset form values
formValues.id = null;
formValues.name = null;
formValues.comment = null;
form.reset();
};
const openViewCommentModal = (company) => {
viewCommentCompany = company;
isViewCommentModalVisible = true;
};
const closeViewCommentModal = () => {
isViewCommentModalVisible = false;
viewCommentCompany = null;
};
const onClickExport = async (id) => {
try {
showIsLoading();
@@ -255,6 +278,10 @@
<TableCellAction>
<TableDropDownEllipsis>
<TableUpdateButton on:click={() => openUpdateModal(company.id)} />
<TableDropDownButton
name="View Comment"
on:click={() => openViewCommentModal(company)}
/>
<TableDropDownButton name="Export" on:click={() => onClickExport(company.id)} />
<TableDeleteButton on:click={() => openDeleteAlert(company)} />
</TableDropDownEllipsis>
@@ -264,21 +291,94 @@
</Table>
<Modal headerText={modalText} visible={isModalVisible} onClose={closeModal} {isSubmitting}>
<FormGrid on:submit={onSubmit} bind:bindTo={form} {isSubmitting}>
<FormColumns>
<FormColumn>
<TextField
required
minLength={1}
maxLength={64}
placeholder="Alices Enterprise Solutions"
bind:value={formValues.name}>Name</TextField
>
</FormColumn>
</FormColumns>
<FormError message={modalError} />
<FormFooter {closeModal} {isSubmitting} />
</FormGrid>
<div class="w-[1000px] p-6">
<form on:submit|preventDefault={onSubmit} bind:this={form}>
<div class="space-y-6">
<div>
<label
for="company-name"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Company Name
</label>
<input
id="company-name"
type="text"
required
minlength="1"
maxlength="64"
placeholder="Alices Enterprise Solutions"
bind:value={formValues.name}
class="w-96 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<div class="flex items-center mb-2">
<label
for="company-comment"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Comment
</label>
<div
class="bg-gray-100 dark:bg-gray-800/60 ml-2 px-2 rounded-md transition-colors duration-200 h-6 flex items-center"
>
<p class="text-slate-600 dark:text-gray-400 text-xs transition-colors duration-200">
optional
</p>
</div>
</div>
<textarea
id="company-comment"
bind:value={formValues.comment}
maxlength={1000000}
rows="20"
placeholder="Add notes about this company..."
class="w-full p-4 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</div>
<FormError message={modalError} />
<FormFooter {closeModal} {isSubmitting} />
</form>
</div>
</Modal>
<Modal
headerText="Company Comment"
visible={isViewCommentModalVisible}
onClose={closeViewCommentModal}
>
<div class="p-8 w-full min-w-[800px] max-w-6xl">
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2">
{viewCommentCompany?.name || 'Company'}
</h3>
</div>
{#if viewCommentCompany?.comment && viewCommentCompany.comment.trim()}
<div
class="bg-gray-50 dark:bg-gray-800 p-8 rounded-lg border min-h-[400px] max-h-[600px] overflow-y-auto"
>
<pre
class="whitespace-pre-wrap text-base text-gray-700 dark:text-gray-300 font-normal leading-relaxed">{viewCommentCompany.comment}</pre>
</div>
{:else}
<div class="bg-gray-50 dark:bg-gray-800 p-8 rounded-lg border text-center">
<p class="text-sm text-gray-500 dark:text-gray-400 italic">No comment available.</p>
</div>
{/if}
<div class="mt-6 flex justify-end">
<button
type="button"
on:click={closeViewCommentModal}
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-md transition-colors duration-200"
>
Close
</button>
</div>
</div>
</Modal>
<DeleteAlert